SQL and querying skill 03 of 8

Window functions

The Window Functions skill makes Claude, Codex or another AI agent write running totals, moving averages, ranking and top-N-per-group that keep every row. It sets PARTITION BY and ORDER BY with a tiebreaker, states the frame explicitly to avoid the default RANGE trap, and wraps any filter on a window value so the query is both correct and fast.

window-functions/SKILL.md, version 1.0.0, about 2,500 words.

Download .mdSkill folder (.zip)

When to use this, and when not to

Use this skill for a calculation that spans a set of related rows but keeps every row in the output: a running total, a moving average, a rank within a group, the top N rows per group, the difference from the previous row, each row's share of its group's total, or runs of consecutive values. The triggers are the OVER clause and any of PARTITION BY, ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, or an ordinary aggregate followed by OVER.

Do not use it when the answer is one row per group with no detail rows kept: that is GROUP BY. Do not use it for joining tables (sql-joins), for structuring nested queries (subqueries-and-ctes), or for walking a hierarchy (recursive CTEs). The defining feature of a window function is that it adds a column computed across other rows without collapsing the current row.

Inputs the agent must have before starting

  1. The SQL engine and version. Window functions need PostgreSQL (all supported versions), MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+ (2018), or SQL Server 2012+. Frame and QUALIFY support vary (see the appendix).
  2. The question and the table, with the grain of one input row.
  3. The partition: the group the calculation resets within. It may be the whole table (no PARTITION BY).
  4. The ordering: the column or columns that define sequence for running, ranking and offset functions, including a tiebreaker column that makes the order unique.
  5. The frame needed: the whole partition, everything up to the current row (running), or a sliding window of n rows.
  6. Whether the result is then filtered on the window value (top N, rank = 1), which needs the query wrapped.

If the ordering column has ties and the person has not given a tiebreaker, ask for one. Without it, ranking and "latest row" results are not reproducible.

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as window-functions/SKILL.md.

---
name: window-functions
description: >
  Write, review and debug SQL window functions across PostgreSQL, MySQL 8+, SQLite 3.25+, SQL Server and other engines: running totals, moving averages, ranking, top-N-per-group, row-to-row deltas, percent-of-total and gaps-and-islands, with correct PARTITION BY, ORDER BY and frame clauses. Use it when a query uses OVER, PARTITION BY, ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE or an aggregate with OVER, or when someone asks for a "running total", "moving average", "rank within each group", "top 3 per category", "previous row's value", "month-over-month change", "percent of total", or says "my running total is wrong" or "my SUM OVER is slow". Do NOT use it for one-row-per-group aggregation with GROUP BY, for joins (see sql-joins), for subquery or CTE structure (see subqueries-and-ctes), or for recursive hierarchy walks.
license: Free to use and adapt under CC BY 4.0, with credit to Afeez Adebayo. Link afeezadebayo.com, https://www.linkedin.com/in/afeez-adebayo/ and this skill's page at https://afeezadebayo.com/skills-md/data/sql-querying/window-functions/.
---

# Window functions

## When to use this, and when not to

Use this skill for a calculation that spans a set of related rows but keeps every row in the output: a running total, a moving average, a rank within a group, the top N rows per group, the difference from the previous row, each row's share of its group's total, or runs of consecutive values. The triggers are the `OVER` clause and any of `PARTITION BY`, `ROW_NUMBER`, `RANK`, `DENSE_RANK`, `NTILE`, `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, or an ordinary aggregate followed by `OVER`.

Do not use it when the answer is one row per group with no detail rows kept: that is `GROUP BY`. Do not use it for joining tables (`sql-joins`), for structuring nested queries (`subqueries-and-ctes`), or for walking a hierarchy (recursive CTEs). The defining feature of a window function is that it adds a column computed across other rows without collapsing the current row.

## Inputs the agent must have before starting

1. The SQL engine and version. Window functions need PostgreSQL (all supported versions), MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+ (2018), or SQL Server 2012+. Frame and `QUALIFY` support vary (see the appendix).
2. The question and the table, with the grain of one input row.
3. The partition: the group the calculation resets within. It may be the whole table (no `PARTITION BY`).
4. The ordering: the column or columns that define sequence for running, ranking and offset functions, including a tiebreaker column that makes the order unique.
5. The frame needed: the whole partition, everything up to the current row (running), or a sliding window of n rows.
6. Whether the result is then filtered on the window value (top N, rank = 1), which needs the query wrapped.

If the ordering column has ties and the person has not given a tiebreaker, ask for one. Without it, ranking and "latest row" results are not reproducible.

## The workflow, step by step

### 1. Confirm you want to keep every row

If the output should be one row per group, use `GROUP BY`. Use a window function only when the detail rows stay and you are adding a cross-row column. Mixing the two intentions is the most common reason a result has the wrong number of rows.

### 2. Set the partition

`PARTITION BY` divides rows into groups that the function restarts for. Omit it to treat the whole result as one partition. The partition is "per customer", "per store", "per category": the thing the running total or rank resets within.

### 3. Set the order, with a tiebreaker

Add `ORDER BY` inside `OVER` for anything sequential: running totals, `LAG`/`LEAD`, and all ranking functions. Decision rule: tied rows are processed in an unspecified order, so append a unique tiebreaker (a primary key, a timestamp plus an id) to make `ROW_NUMBER`, "latest row" and running results deterministic. `row_number() OVER (PARTITION BY dept ORDER BY salary DESC)` is non-deterministic across equal salaries; `ORDER BY salary DESC, emp_id` is not.

### 4. Specify the frame explicitly for ordered aggregates

This is the step that most queries get wrong. When you use an aggregate (`SUM`, `AVG`, `COUNT`, `MIN`, `MAX`) with `ORDER BY` and no frame clause, the default frame is:

```
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
```

That default includes the current row and all its peers (rows equal on the `ORDER BY` key), so tied rows all receive the same cumulative value rather than a true row-by-row running total. It is also slower, because the engine cannot tell in advance how many rows each frame covers and often spills to a spool.

Decision rule: state the frame explicitly whenever you use an ordered aggregate.

- Running total: `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`.
- Trailing moving average of n rows: `ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW`.
- Centred moving average: `ROWS BETWEEN k PRECEDING AND k FOLLOWING`.

`ROWS` counts physical rows from the current row. `RANGE` works on value peers. `GROUPS` counts peer groups. Use `ROWS` unless you specifically want ties folded together, in which case `RANGE` is correct and deliberate. On one SQL Server row-mode test of two million rows, the implicit `RANGE` running total ran about four times slower than the explicit `ROWS` form (roughly 20 seconds versus 4), because `RANGE` forced an on-disk spool; the exact numbers are engine and version specific, but the direction holds. Adding `ORDER BY` to an unframed aggregate can also change the result, not just the speed, so make the frame explicit rather than relying on the default.

Ranking and offset functions (`ROW_NUMBER`, `RANK`, `DENSE_RANK`, `NTILE`, `LAG`, `LEAD`, `PERCENT_RANK`, `CUME_DIST`) ignore the frame and always see the whole partition. Do not add a frame clause to them.

### 5. Pick the right ranking function

| Function | Ties | Gaps after ties | Use for |
|---|---|---|---|
| `ROW_NUMBER()` | Broken arbitrarily | No | Top-N-per-group, deduplication, "one row per key" |
| `RANK()` | Share a rank | Yes (skips) | Competition ranking where ties skip numbers |
| `DENSE_RANK()` | Share a rank | No | Ranking where the next distinct value is the next number |
| `NTILE(n)` | n equal buckets | n/a | Quartiles, deciles; unreliable on small partitions |

For the top N rows per group, compute `ROW_NUMBER()` in a subquery or CTE, then filter `WHERE rn <= N` outside it (step 7). For deduplication, keep `rn = 1`.

### 6. Use offsets and shares correctly

`LAG(col, 1)` and `LEAD(col, 1)` read the previous and next row within the partition and return `NULL` at the edges; pass a default (`LAG(col, 1, 0)`) or wrap in `COALESCE` when a number is needed. A row's share of its group is `col / SUM(col) OVER (PARTITION BY grp)`; guard against divide-by-zero with `NULLIF(SUM(col) OVER (...), 0)`. For month-over-month change, subtract the `LAG` value from the current value.

### 7. Filter on a window result by wrapping the query

Window functions run after `WHERE`, `GROUP BY` and `HAVING`, so they cannot appear in those clauses. To filter on a rank or a row number, compute it in a subquery or CTE and filter in the outer query:

```sql
SELECT * FROM (
    SELECT p.*,
           ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn
    FROM products p
) ranked
WHERE rn <= 3;
```

On engines that support `QUALIFY` (Snowflake, BigQuery, DuckDB, Teradata), `QUALIFY rn <= 3` does this inline without the wrapper. PostgreSQL, MySQL, SQLite and SQL Server do not have `QUALIFY`; use the subquery.

### 8. Name a reused window

When several functions share the same `OVER` specification, define it once with a `WINDOW` clause and reference it by name. The `WINDOW` clause goes after `WHERE`, `GROUP BY` and `HAVING`:

```sql
SELECT sum(amount) OVER w, avg(amount) OVER w
FROM payments
WINDOW w AS (PARTITION BY store_id ORDER BY paid_at
             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
```

### 9. Make it perform

An explicit `ROWS` frame avoids the spool that the default `RANGE` frame can force. The `PARTITION BY` and `ORDER BY` drive a sort, so an index on `(partition_key, order_key)` can let the engine skip it. Run `EXPLAIN` and check whether a sort or a spool dominates.

### 10. Verify before handing over

Confirm the ordering is deterministic (a tiebreaker is present), the frame is explicit for every ordered aggregate, edge `NULL`s from `LAG`/`LEAD` are handled, any filter on a window value is correctly wrapped, and the headline numbers reproduce on a re-run.

## Worked example, end to end

Daily sales per store in `sales(store_id, sale_date, amount)`, one row per sale. The question: a running total of revenue per store by day, a 7-day trailing average, and each store ranked by its total revenue.

A first attempt at the running total is subtly wrong:

```sql
SELECT store_id, sale_date, amount,
       SUM(amount) OVER (PARTITION BY store_id ORDER BY sale_date) AS running
FROM sales;
```

With several sales on the same `sale_date`, the default `RANGE` frame treats them as peers, so every sale on that date shows the same cumulative figure (the total through the end of that day) rather than a true running total, and the query is slower than it needs to be. The corrected version states the frame and adds a tiebreaker so the order is deterministic:

```sql
SELECT store_id, sale_date, amount,
       SUM(amount) OVER (
           PARTITION BY store_id
           ORDER BY sale_date, id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running,
       AVG(amount) OVER (
           PARTITION BY store_id
           ORDER BY sale_date, id
           ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS avg_7row
FROM sales;
```

To rank stores by total revenue, aggregate first, then rank, keeping every store row:

```sql
WITH store_totals AS (
    SELECT store_id, SUM(amount) AS revenue
    FROM sales GROUP BY store_id
)
SELECT store_id, revenue,
       DENSE_RANK() OVER (ORDER BY revenue DESC) AS revenue_rank
FROM store_totals;
```

For the top three products per category, use `ROW_NUMBER` in a CTE and filter outside, as in step 7. Each pattern keeps the detail rows and adds exactly one computed column.

## Templates you can use verbatim

```sql
-- Running total (always frame it)
SUM(amount) OVER (PARTITION BY k ORDER BY ts, id
                  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- Trailing 7-row moving average
AVG(amount) OVER (PARTITION BY k ORDER BY ts, id
                  ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- Top N per group: compute then filter
SELECT * FROM (
    SELECT t.*, ROW_NUMBER() OVER (PARTITION BY grp ORDER BY metric DESC, id) AS rn
    FROM t
) s WHERE rn <= :n;

-- Deduplicate: keep one row per key
SELECT * FROM (
    SELECT t.*, ROW_NUMBER() OVER (PARTITION BY natural_key ORDER BY updated_at DESC, id) AS rn
    FROM t
) s WHERE rn = 1;

-- Row-to-row change
value - LAG(value, 1, 0) OVER (PARTITION BY k ORDER BY ts, id) AS delta

-- Share of group total (divide-by-zero safe)
value / NULLIF(SUM(value) OVER (PARTITION BY k), 0) AS share

-- Named window reused across functions
SELECT first_value(price) OVER w, last_value(price) OVER w
FROM t
WINDOW w AS (PARTITION BY k ORDER BY ts
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);
```

A note on `LAST_VALUE`: with the default running frame it returns the current row, not the partition's last row. Use `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` (as above) to get the true last value.

Prompts that should trigger this skill: "Running total of revenue by day per store", "Rank customers by spend within each region", "Show the top 3 products in each category", "Month-over-month growth per account", "Each row's percentage of its group total".

## Failure modes and anti-patterns

1. **The default RANGE frame gives a wrong or slow running total.** Spot it when same-key tied rows all show the same cumulative value, or when a `SUM OVER (ORDER BY ...)` is unexpectedly slow. Fix: add `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` and a unique tiebreaker.
2. **Non-deterministic ranking.** Spot it when `ROW_NUMBER` or "latest row" results change between runs on tied data. Fix: add a unique tiebreaker to the window `ORDER BY`.
3. **Filtering on a window function in WHERE.** Spot it as an error like "window functions are not allowed in WHERE". Fix: wrap the query in a subquery or CTE and filter the computed column outside, or use `QUALIFY` where supported.
4. **A window function used where GROUP BY was meant.** Spot it when the result has one row per input instead of one per group. Fix: use `GROUP BY` for a collapsed summary, and keep the window function only when detail rows must stay.
5. **Edge NULLs and tiny NTILE buckets.** Spot `LAG`/`LEAD` NULLs at partition edges feeding into arithmetic, and `NTILE(100)` on a two-row partition returning buckets 1 and 2 rather than true percentiles. Fix: supply a default or `COALESCE` for offsets, and use coarse buckets on small partitions.

## Quality bar

- Every ordered aggregate has an explicit frame; no ordered `SUM`/`AVG`/`COUNT` relies on the default `RANGE`.
- The window `ORDER BY` is deterministic (a unique tiebreaker is present) wherever order affects the result.
- The right ranking function is used for the tie behaviour required, and top-N uses `ROW_NUMBER` filtered outside the window.
- `LAG`/`LEAD` edge `NULL`s are handled; share-of-total guards divide-by-zero.
- Any filter on a window value is wrapped in a subquery or CTE (or uses `QUALIFY` on a supporting engine).
- `GROUP BY` is used instead where a collapsed summary was the real intent.
- The plan was checked with `EXPLAIN`, and an index on `(partition, order)` is considered where the sort dominates.

## Data handling and safety

Window functions read data and produce reports, so they are low risk on their own. Do not paste real personal, health or financial values into prompts or shared logs; refer to columns by name and type. When a window query feeds a destructive statement (for example, deleting all but the latest row per key), show the `SELECT` that lists the affected rows first, report the count, and require explicit human confirmation before running the delete.

## Appendix: engine-specific notes (verify before relying on these)

- **Availability**: PostgreSQL (all supported versions), MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+ (2018), SQL Server 2012+, Oracle. MySQL 5.7 and earlier have no window functions.
- **Default frame**: `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` with `ORDER BY`, whole partition without, in PostgreSQL, MySQL and SQL Server alike. Make it explicit regardless.
- **GROUPS frame**: supported in PostgreSQL 11+; not in MySQL 8.0 or SQL Server (which support `ROWS` and `RANGE` only).
- **QUALIFY**: Snowflake, BigQuery, DuckDB and Teradata only. PostgreSQL, MySQL, SQLite and SQL Server need the subquery wrapper.
- **Named windows (`WINDOW` clause)**: PostgreSQL, MySQL 8.0+, SQLite, MariaDB. SQL Server has no `WINDOW` clause; repeat the `OVER` specification.
- **`RANGE` with numeric or interval offsets** (for example `RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW`): PostgreSQL 11+ and recent others; check your engine and version.
- **`FILTER (WHERE ...)` on window aggregates**: PostgreSQL supports it; most other engines do not, so use `CASE` inside the aggregate instead.

## Sources

- PostgreSQL 18 documentation, "Window Functions" tutorial and "Window Function Calls": no row collapsing, execution after WHERE/GROUP BY/HAVING, default frame wording, the `WINDOW` clause, filtering via a sub-select. Verified 29 October 2026.
- MySQL 8.0 Reference Manual, "Window Function Frame Specification": default `RANGE` vs `ROWS`, peers, functions that ignore frames, and the note that adding `ORDER BY` can change `SUM()` results. Verified 29 October 2026.
- Itzik Ben-Gan, "The Curious Case of Window Functions" / framing performance, sqlperformance.com: measured `RANGE` vs `ROWS` running-total costs and the recommendation to state the frame explicitly. Verified 29 October 2026.
- ThoughtSpot (formerly Mode) SQL tutorial, "Window Functions": OVER, PARTITION BY, ranking functions, NTILE, LAG/LEAD, and the small-partition NTILE caution. Verified 29 October 2026.
- SQLite, MariaDB and Microsoft SQL Server documentation for window-function availability, `QUALIFY` absence and `GROUPS` support. Verified 29 October 2026.

The workflow, step by step

1. Confirm you want to keep every row

If the output should be one row per group, use GROUP BY. Use a window function only when the detail rows stay and you are adding a cross-row column. Mixing the two intentions is the most common reason a result has the wrong number of rows.

2. Set the partition

PARTITION BY divides rows into groups that the function restarts for. Omit it to treat the whole result as one partition. The partition is "per customer", "per store", "per category": the thing the running total or rank resets within.

3. Set the order, with a tiebreaker

Add ORDER BY inside OVER for anything sequential: running totals, LAG/LEAD, and all ranking functions. Decision rule: tied rows are processed in an unspecified order, so append a unique tiebreaker (a primary key, a timestamp plus an id) to make ROW_NUMBER, "latest row" and running results deterministic. row_number() OVER (PARTITION BY dept ORDER BY salary DESC) is non-deterministic across equal salaries; ORDER BY salary DESC, emp_id is not.

4. Specify the frame explicitly for ordered aggregates

This is the step that most queries get wrong. When you use an aggregate (SUM, AVG, COUNT, MIN, MAX) with ORDER BY and no frame clause, the default frame is:

RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

That default includes the current row and all its peers (rows equal on the ORDER BY key), so tied rows all receive the same cumulative value rather than a true row-by-row running total. It is also slower, because the engine cannot tell in advance how many rows each frame covers and often spills to a spool.

Decision rule: state the frame explicitly whenever you use an ordered aggregate.

  • Running total: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
  • Trailing moving average of n rows: ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW.
  • Centred moving average: ROWS BETWEEN k PRECEDING AND k FOLLOWING.

ROWS counts physical rows from the current row. RANGE works on value peers. GROUPS counts peer groups. Use ROWS unless you specifically want ties folded together, in which case RANGE is correct and deliberate. On one SQL Server row-mode test of two million rows, the implicit RANGE running total ran about four times slower than the explicit ROWS form (roughly 20 seconds versus 4), because RANGE forced an on-disk spool; the exact numbers are engine and version specific, but the direction holds. Adding ORDER BY to an unframed aggregate can also change the result, not just the speed, so make the frame explicit rather than relying on the default.

Ranking and offset functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, PERCENT_RANK, CUME_DIST) ignore the frame and always see the whole partition. Do not add a frame clause to them.

5. Pick the right ranking function

FunctionTiesGaps after tiesUse for
ROW_NUMBER()Broken arbitrarilyNoTop-N-per-group, deduplication, "one row per key"
RANK()Share a rankYes (skips)Competition ranking where ties skip numbers
DENSE_RANK()Share a rankNoRanking where the next distinct value is the next number
NTILE(n)n equal bucketsn/aQuartiles, deciles; unreliable on small partitions

For the top N rows per group, compute ROW_NUMBER() in a subquery or CTE, then filter WHERE rn <= N outside it (step 7). For deduplication, keep rn = 1.

6. Use offsets and shares correctly

LAG(col, 1) and LEAD(col, 1) read the previous and next row within the partition and return NULL at the edges; pass a default (LAG(col, 1, 0)) or wrap in COALESCE when a number is needed. A row's share of its group is col / SUM(col) OVER (PARTITION BY grp); guard against divide-by-zero with NULLIF(SUM(col) OVER (...), 0). For month-over-month change, subtract the LAG value from the current value.

7. Filter on a window result by wrapping the query

Window functions run after WHERE, GROUP BY and HAVING, so they cannot appear in those clauses. To filter on a rank or a row number, compute it in a subquery or CTE and filter in the outer query:

SELECT * FROM (
    SELECT p.*,
           ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn
    FROM products p
) ranked
WHERE rn <= 3;

On engines that support QUALIFY (Snowflake, BigQuery, DuckDB, Teradata), QUALIFY rn <= 3 does this inline without the wrapper. PostgreSQL, MySQL, SQLite and SQL Server do not have QUALIFY; use the subquery.

8. Name a reused window

When several functions share the same OVER specification, define it once with a WINDOW clause and reference it by name. The WINDOW clause goes after WHERE, GROUP BY and HAVING:

SELECT sum(amount) OVER w, avg(amount) OVER w
FROM payments
WINDOW w AS (PARTITION BY store_id ORDER BY paid_at
             ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);

9. Make it perform

An explicit ROWS frame avoids the spool that the default RANGE frame can force. The PARTITION BY and ORDER BY drive a sort, so an index on (partition_key, order_key) can let the engine skip it. Run EXPLAIN and check whether a sort or a spool dominates.

10. Verify before handing over

Confirm the ordering is deterministic (a tiebreaker is present), the frame is explicit for every ordered aggregate, edge NULLs from LAG/LEAD are handled, any filter on a window value is correctly wrapped, and the headline numbers reproduce on a re-run.

Worked example, end to end

Daily sales per store in sales(store_id, sale_date, amount), one row per sale. The question: a running total of revenue per store by day, a 7-day trailing average, and each store ranked by its total revenue.

A first attempt at the running total is subtly wrong:

SELECT store_id, sale_date, amount,
       SUM(amount) OVER (PARTITION BY store_id ORDER BY sale_date) AS running
FROM sales;

With several sales on the same sale_date, the default RANGE frame treats them as peers, so every sale on that date shows the same cumulative figure (the total through the end of that day) rather than a true running total, and the query is slower than it needs to be. The corrected version states the frame and adds a tiebreaker so the order is deterministic:

SELECT store_id, sale_date, amount,
       SUM(amount) OVER (
           PARTITION BY store_id
           ORDER BY sale_date, id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running,
       AVG(amount) OVER (
           PARTITION BY store_id
           ORDER BY sale_date, id
           ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS avg_7row
FROM sales;

To rank stores by total revenue, aggregate first, then rank, keeping every store row:

WITH store_totals AS (
    SELECT store_id, SUM(amount) AS revenue
    FROM sales GROUP BY store_id
)
SELECT store_id, revenue,
       DENSE_RANK() OVER (ORDER BY revenue DESC) AS revenue_rank
FROM store_totals;

For the top three products per category, use ROW_NUMBER in a CTE and filter outside, as in step 7. Each pattern keeps the detail rows and adds exactly one computed column.

Templates you can use verbatim

-- Running total (always frame it)
SUM(amount) OVER (PARTITION BY k ORDER BY ts, id
                  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- Trailing 7-row moving average
AVG(amount) OVER (PARTITION BY k ORDER BY ts, id
                  ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- Top N per group: compute then filter
SELECT * FROM (
    SELECT t.*, ROW_NUMBER() OVER (PARTITION BY grp ORDER BY metric DESC, id) AS rn
    FROM t
) s WHERE rn <= :n;

-- Deduplicate: keep one row per key
SELECT * FROM (
    SELECT t.*, ROW_NUMBER() OVER (PARTITION BY natural_key ORDER BY updated_at DESC, id) AS rn
    FROM t
) s WHERE rn = 1;

-- Row-to-row change
value - LAG(value, 1, 0) OVER (PARTITION BY k ORDER BY ts, id) AS delta

-- Share of group total (divide-by-zero safe)
value / NULLIF(SUM(value) OVER (PARTITION BY k), 0) AS share

-- Named window reused across functions
SELECT first_value(price) OVER w, last_value(price) OVER w
FROM t
WINDOW w AS (PARTITION BY k ORDER BY ts
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);

A note on LAST_VALUE: with the default running frame it returns the current row, not the partition's last row. Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (as above) to get the true last value.

Prompts that should trigger this skill: "Running total of revenue by day per store", "Rank customers by spend within each region", "Show the top 3 products in each category", "Month-over-month growth per account", "Each row's percentage of its group total".

Failure modes and anti-patterns

  1. The default RANGE frame gives a wrong or slow running total. Spot it when same-key tied rows all show the same cumulative value, or when a SUM OVER (ORDER BY ...) is unexpectedly slow. Fix: add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and a unique tiebreaker.
  2. Non-deterministic ranking. Spot it when ROW_NUMBER or "latest row" results change between runs on tied data. Fix: add a unique tiebreaker to the window ORDER BY.
  3. Filtering on a window function in WHERE. Spot it as an error like "window functions are not allowed in WHERE". Fix: wrap the query in a subquery or CTE and filter the computed column outside, or use QUALIFY where supported.
  4. A window function used where GROUP BY was meant. Spot it when the result has one row per input instead of one per group. Fix: use GROUP BY for a collapsed summary, and keep the window function only when detail rows must stay.
  5. Edge NULLs and tiny NTILE buckets. Spot LAG/LEAD NULLs at partition edges feeding into arithmetic, and NTILE(100) on a two-row partition returning buckets 1 and 2 rather than true percentiles. Fix: supply a default or COALESCE for offsets, and use coarse buckets on small partitions.

Quality bar

  • Every ordered aggregate has an explicit frame; no ordered SUM/AVG/COUNT relies on the default RANGE.
  • The window ORDER BY is deterministic (a unique tiebreaker is present) wherever order affects the result.
  • The right ranking function is used for the tie behaviour required, and top-N uses ROW_NUMBER filtered outside the window.
  • LAG/LEAD edge NULLs are handled; share-of-total guards divide-by-zero.
  • Any filter on a window value is wrapped in a subquery or CTE (or uses QUALIFY on a supporting engine).
  • GROUP BY is used instead where a collapsed summary was the real intent.
  • The plan was checked with EXPLAIN, and an index on (partition, order) is considered where the sort dominates.

Data handling and safety

Window functions read data and produce reports, so they are low risk on their own. Do not paste real personal, health or financial values into prompts or shared logs; refer to columns by name and type. When a window query feeds a destructive statement (for example, deleting all but the latest row per key), show the SELECT that lists the affected rows first, report the count, and require explicit human confirmation before running the delete.

Appendix: engine-specific notes (verify before relying on these)

  • Availability: PostgreSQL (all supported versions), MySQL 8.0+, MariaDB 10.2+, SQLite 3.25+ (2018), SQL Server 2012+, Oracle. MySQL 5.7 and earlier have no window functions.
  • Default frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW with ORDER BY, whole partition without, in PostgreSQL, MySQL and SQL Server alike. Make it explicit regardless.
  • GROUPS frame: supported in PostgreSQL 11+; not in MySQL 8.0 or SQL Server (which support ROWS and RANGE only).
  • QUALIFY: Snowflake, BigQuery, DuckDB and Teradata only. PostgreSQL, MySQL, SQLite and SQL Server need the subquery wrapper.
  • Named windows (WINDOW clause): PostgreSQL, MySQL 8.0+, SQLite, MariaDB. SQL Server has no WINDOW clause; repeat the OVER specification.
  • RANGE with numeric or interval offsets (for example RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW): PostgreSQL 11+ and recent others; check your engine and version.
  • FILTER (WHERE ...) on window aggregates: PostgreSQL supports it; most other engines do not, so use CASE inside the aggregate instead.

Sources

  • PostgreSQL 18 documentation, "Window Functions" tutorial and "Window Function Calls": no row collapsing, execution after WHERE/GROUP BY/HAVING, default frame wording, the WINDOW clause, filtering via a sub-select. Verified 29 October 2026.
  • MySQL 8.0 Reference Manual, "Window Function Frame Specification": default RANGE vs ROWS, peers, functions that ignore frames, and the note that adding ORDER BY can change SUM() results. Verified 29 October 2026.
  • Itzik Ben-Gan, "The Curious Case of Window Functions" / framing performance, sqlperformance.com: measured RANGE vs ROWS running-total costs and the recommendation to state the frame explicitly. Verified 29 October 2026.
  • ThoughtSpot (formerly Mode) SQL tutorial, "Window Functions": OVER, PARTITION BY, ranking functions, NTILE, LAG/LEAD, and the small-partition NTILE caution. Verified 29 October 2026.
  • SQLite, MariaDB and Microsoft SQL Server documentation for window-function availability, QUALIFY absence and GROUPS support. Verified 29 October 2026.

When to use this skill, and when not to

Use it for a calculation that spans related rows but keeps every row: a running total, a moving average, a rank within a group, the top N rows per group, the change from the previous row, or each row’s share of its group. The triggers are the OVER clause and functions such as ROW_NUMBER, RANK, LAG, LEAD and NTILE.

Do not use it when the answer is one row per group with no detail kept (that is GROUP BY), for joining tables (that is sql-joins), for structuring nested queries (that is subqueries-and-ctes), or for walking a hierarchy. Its defining feature is adding a cross-row column without collapsing the current row.

Who it is for

Analysts, data and backend engineers, and anyone using an AI agent to turn detailed rows into running, ranked or period-over-period figures. It suits people whose running total is quietly wrong because of the default frame, whose ranking changes between runs, or who hit the error that a window function is not allowed in WHERE.

What is inside

The skill carries a ten-step workflow with decision rules and thresholds, not vague advice. It explains when to reach for a window function rather than GROUP BY, how to set PARTITION BY and a deterministic ORDER BY, and, most of all, why the default RANGE frame gives a wrong and slow running total and how an explicit ROWS frame fixes both. It covers choosing the right ranking function, top-N-per-group, LAG and LEAD edge NULLs, filtering on a window result by wrapping the query, and the WINDOW clause. It includes a worked example, copy-and-use templates, five failure modes, a quality bar, and an engine appendix for PostgreSQL, MySQL, SQLite and SQL Server kept apart from the core.

Example prompts

  • “Running total of revenue by day per store.”
  • “Rank customers by spend within each region.”
  • “Show the top 3 products in each category.”
  • “My running total repeats the same number on tied dates.”

Example output

The skill returns a framed, deterministic query with the reasoning stated. A shortened version:

Running total of revenue per store by day. Several sales share a date, so the default RANGE frame would give every same-day row the day’s full total; I use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and order by sale_date, id for a true row-by-row total. The 7-day average uses ROWS BETWEEN 6 PRECEDING AND CURRENT ROW. Verify: the row count equals the input, since every row is kept.

What the agent needs from you

The question and the table, the partition the calculation resets within, the ordering column with a unique tiebreaker, the frame you need, and whether the result is then filtered on a rank or row number. The skill asks for the tiebreaker rather than guessing, because without it ranking and latest-row results are not reproducible, and which SQL engine you run, because frame and QUALIFY support differ.

How to install and use it

In the Claude apps, download the SKILL.md from this page, put it in a folder named window-functions, and upload the folder as a skill in settings. In Claude Code, save it as ~/.claude/skills/window-functions/SKILL.md, or inside a project under .claude/skills/. In Codex and other Agent Skills tools, drop it in the tool’s skills folder, often .agents/skills/. Then ask the agent for a running total, a ranking or a top-N query in plain language; the description triggers it.

How to use window functions step by step

  1. Install the skill as window-functions/SKILL.md.
  2. Give the agent the question, the partition, the ordering and the engine.
  3. It confirms every row should be kept, or routes you to GROUP BY.
  4. It sets PARTITION BY and a deterministic ORDER BY with a tiebreaker.
  5. It states the frame explicitly, using ROWS for running totals and moving averages.
  6. It wraps any filter on a window value, checks the plan, and confirms the numbers reproduce.

Worked example

Asked for a running total of revenue per store by day, the skill caught that the obvious SUM OVER (ORDER BY sale_date) used the default RANGE frame, so every sale on the same day showed the day’s full cumulative figure rather than a true row-by-row total, and ran slower because RANGE forced a spool. It switched to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, added id as a tiebreaker for a deterministic order, and added a 7-day trailing average with a ROWS frame. Every detail row was kept.

Situations this skill handles

SituationWhat the skill does
A running total repeats on tiesSwitches the default RANGE frame to an explicit ROWS frame
A SUM OVER is slowReplaces RANGE with ROWS to avoid the on-disk spool
Ranking changes between runsAdds a unique tiebreaker to the window ORDER BY
Top N per group neededComputes ROW_NUMBER in a CTE, then filters outside it
A window function in WHERE errorsWraps the query so the computed column can be filtered
LAST_VALUE returns the current rowSets the frame to the whole partition for the true last value

What makes this skill different

Most window-function skills list the functions and show a running total, without saying that the default frame makes that running total wrong on tied rows and slower than it needs to be. This one leads with the default RANGE trap, gives the explicit ROWS frame that fixes correctness and speed, and makes ranking deterministic with a tiebreaker. It explains the execution order that forces a subquery wrapper to filter on a window value, notes which engines offer QUALIFY, and stays engine-agnostic with a separate appendix for frame and support differences.

Common mistakes

Relying on the default RANGE frame for a running total, ranking without a tiebreaker, trying to filter a window function in WHERE, using a window function where GROUP BY was meant, and feeding LAG or LEAD edge NULLs into arithmetic. The skill names each one, explains how to spot it, and says what to do instead.

Pair it with Subqueries and CTEs for the wrapper that filters on a rank, and with SQL joins when a result must be combined with another table. To read a single variable’s shape first, see Descriptive statistics and Exploratory data analysis. Browse the full SQL and querying skills set in the Data category.

Frequently asked questions

A window function computes a value across a set of rows related to the current row, such as a running total or a rank, without collapsing those rows into one. Unlike GROUP BY, every input row stays in the output with the computed column added. The OVER clause is what marks a function as a window function.

Because of the default frame. An ordered SUM with no frame clause uses RANGE, which includes every row that ties on the ORDER BY value, so all same-value rows show the same cumulative figure. Add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and a unique tiebreaker for a true row-by-row total.

ROW_NUMBER gives every row a unique number, breaking ties arbitrarily. RANK gives tied rows the same number and then skips, so ranks can jump from 1 to 3. DENSE_RANK gives tied rows the same number without skipping. Use ROW_NUMBER for top-N and deduplication, where uniqueness matters.

Window functions run after WHERE, GROUP BY and HAVING, so they cannot be referenced there. To filter on a rank or row number, compute it in a subquery or CTE and filter in the outer query. Some engines, such as Snowflake and BigQuery, offer QUALIFY to do this inline.

Compute ROW_NUMBER() OVER (PARTITION BY the group ORDER BY the metric) in a subquery or CTE, then filter WHERE rn is at most N in the outer query. ROW_NUMBER is the right choice because it is unique, so you get exactly N rows per group even when values tie.

PostgreSQL, MySQL 8.0 and later, MariaDB 10.2+, SQLite 3.25+ and SQL Server 2012 and later all support them. MySQL 5.7 and earlier do not. The core syntax is shared; frame options such as GROUPS and the WINDOW clause differ by engine, which the SKILL.md appendix covers.

Changelog

  • 1.0.0, 29 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.