This SQL window functions cheat sheet is the single page you can keep open while you write analytic SQL. Every function, every frame clause, every common pattern โ with copy-ready syntax and a link to a practice problem you can solve right now.
Whether youโre prepping for a SQL interview, debugging a dashboard query, or just trying to remember the difference between RANK() and DENSE_RANK() at 11pm, this guide has you covered.
"Xorthax's quartermaster doesn't memorize every starship manual โ they keep a reference card on the bridge. You should too."
Window functions are powerful but unforgiving. One missing ORDER BY, one wrong frame clause, and your โrunning totalโ returns the same number on every row. This cheat sheet gives you the exact patterns that work, organized so you can find them fast.
Every window function follows the same shape. Memorize this skeleton and you can always fill in the blanks:
FUNCTION(expr) OVER (
[PARTITION BY partition_col, ...] -- group rows into windows
[ORDER BY sort_col [ASC|DESC]] -- order within each window
[ROWS|RANGE|GROUPS BETWEEN -- which rows count for each calc
frame_start AND frame_end]
)
| Clause | What it does | Required? |
|---|---|---|
OVER ( ... ) | Marks the function as a window function. | โ Always |
PARTITION BY | Splits rows into independent groups. | Optional โ defaults to one window over all rows |
ORDER BY | Defines row order inside each partition. | Required for ranking, value, & distribution functions |
ROWS / RANGE / GROUPS | Selects which rows feed each calculation. | Optional โ has a default that often surprises you |
๐ Want the deep dive on each clause? See How Window Functions Work.
Every window function belongs to one of four families. Knowing the category tells you what defaults to expect.
| Category | Functions | Needs ORDER BY? | Accepts frame? |
|---|---|---|---|
| Aggregate | SUM, AVG, COUNT, MIN, MAX |
Optional | โ Yes |
| Ranking | ROW_NUMBER, RANK, DENSE_RANK, NTILE |
โ Required | โ No |
| Value / Navigation | LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE |
โ Required | FIRST/LAST/NTH only |
| Distribution | PERCENT_RANK, CUME_DIST |
โ Required | โ No |
The same aggregates you already know (SUM, AVG, etc.) โ but applied per-row, without collapsing the result set.
| Function | Use it whenโฆ |
|---|---|
SUM(expr) OVER (...) | Running totals, cumulative revenue, partition totals on every row. |
AVG(expr) OVER (...) | Moving averages, rolling means, partition-level averages. |
COUNT(*) OVER (...) | Running counts, partition sizes, "how many in my group so far". |
MIN(expr) OVER (...) | Lowest seen so far, partition minimum repeated on every row. |
MAX(expr) OVER (...) | Highest seen so far, partition max repeated on every row. |
SELECT
order_date,
amount,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM Orders;
๐ Full guide: Aggregate Window Functions.
Assign a position to each row inside its partition. The differences only matter when there are ties.
| Function | How it handles ties | Output for values 700, 700, 600 |
|---|---|---|
ROW_NUMBER() | Always unique โ ties broken arbitrarily | 1, 2, 3 |
RANK() | Same rank for ties, then skip | 1, 1, 3 |
DENSE_RANK() | Same rank for ties, no skip | 1, 1, 2 |
NTILE(n) | Splits rows into n roughly equal buckets | e.g. NTILE(4) โ 1โ4 quartile bucket |
SELECT
CustomerID,
Region,
OrderAmount,
ROW_NUMBER() OVER (PARTITION BY Region ORDER BY OrderAmount DESC) AS rn,
RANK() OVER (PARTITION BY Region ORDER BY OrderAmount DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY Region ORDER BY OrderAmount DESC) AS drnk,
NTILE(4) OVER (PARTITION BY Region ORDER BY OrderAmount DESC) AS quartile
FROM Orders;
๐ Full guide: Ranking Functions.
Reach into other rows of the partition without writing a self-join.
| Function | What it returns | Common use |
|---|---|---|
LAG(expr, offset, default) |
Value from offset rows before the current row | Month-over-month change, time between events |
LEAD(expr, offset, default) |
Value from offset rows after the current row | "Days until next order", forecasting checks |
FIRST_VALUE(expr) |
First row in the window frame | Onboarding price, customerโs first order date |
LAST_VALUE(expr) |
Last row in the window frame โ ๏ธ | Latest snapshot โ requires an explicit unbounded frame |
NTH_VALUE(expr, n) |
The n-th row in the window frame | "Second-highest", "third event", etc. |
SELECT
product_id,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product_id ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (PARTITION BY product_id ORDER BY month) AS mom_change
FROM monthly_revenue;
โ ๏ธ LAST_VALUE gotcha: with the default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) it returns the current row, not the partitionโs last row. Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
๐ Full guides: LEAD / LAG ยท FIRST / LAST / NTH.
Express where each row sits relative to its peers, on a 0โ1 scale.
| Function | Formula | Use it whenโฆ |
|---|---|---|
PERCENT_RANK() |
(rank - 1) / (rows - 1) |
You want a percentile rank โ first row is 0.0, last is 1.0. |
CUME_DIST() |
rows โค current / total rows |
You want cumulative distribution โ "what fraction of rows โค this value". |
WITH Ranked AS (
SELECT *,
PERCENT_RANK() OVER (PARTITION BY Region ORDER BY Amount DESC) AS pr
FROM Orders
)
SELECT * FROM Ranked WHERE pr <= 0.10;
๐ Full guide: Percentile & Distribution.
The frame clause decides which rows are visible to your function for each row's calculation. Most bugs in window queries trace back to this.
| Frame | What "current row" means | Use whenโฆ |
|---|---|---|
ROWS |
A specific number of rows before/after the current row | You want exact row counts (e.g. last 7 rows for a 7-row moving avg) |
RANGE |
All rows whose ORDER BY value matches the bound |
You want value-based bounds (e.g. all rows on the same date) |
GROUPS |
A specific number of peer groups (rows with equal sort keys) | Postgres 11+ โ when you want N distinct sort values, ties counted together |
| Boundary | Meaning |
|---|---|
UNBOUNDED PRECEDING | From the first row of the partition |
n PRECEDING | n rows before the current row |
CURRENT ROW | The current row itself |
n FOLLOWING | n rows after the current row |
UNBOUNDED FOLLOWING | Through the last row of the partition |
| Situation | Default frame | What it produces |
|---|---|---|
SUM() OVER (ORDER BY ...) |
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
A running total โ |
SUM() OVER (PARTITION BY ...) (no ORDER BY) |
UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
Partition total on every row โ |
LAST_VALUE() OVER (ORDER BY ...) |
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
Returns the current row, not the last โ |
The handful of patterns that cover ~80% of real analytics SQL.
| Pattern | SQL | Practice |
|---|---|---|
| Running total | SUM(amount) OVER (ORDER BY order_date) |
#2 |
| Partition total on every row | SUM(amount) OVER (PARTITION BY customer_id) |
#1 |
| 7-day moving average | AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) |
#5 |
| Top-N per group | ROW_NUMBER() OVER (PARTITION BY group ORDER BY metric DESC) AS rn (filter rn <= N) |
#3 |
| Deduplication (keep latest) | ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC) (filter = 1) |
#22 |
| Month-over-month change | amount - LAG(amount) OVER (PARTITION BY id ORDER BY month) |
#6 |
| Days since last event | order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) |
#4 |
| Percent of total | amount / SUM(amount) OVER () * 100 |
#7 |
| Percent of group | amount / SUM(amount) OVER (PARTITION BY group) * 100 |
#7 |
| First / last value per partition | FIRST_VALUE(price) OVER (PARTITION BY product ORDER BY date) |
#21 |
| Quartile bucketing | NTILE(4) OVER (ORDER BY revenue DESC) |
#33 |
| Gap between events | date - LAG(date) OVER (PARTITION BY id ORDER BY date) |
Gap & Island |
Match the question youโre trying to answer to the function family.
| If you need toโฆ | Reach for |
|---|---|
| Add an aggregate alongside detail rows | SUM/AVG/COUNT OVER (PARTITION BY ...) |
| Compute a running total | SUM OVER (ORDER BY ...) |
| Compute a moving / rolling window | AVG OVER (ORDER BY ... ROWS BETWEEN n PRECEDING AND CURRENT ROW) |
| Number every row uniquely | ROW_NUMBER() |
| Rank with ties + gaps (1, 1, 3) | RANK() |
| Rank with ties, no gaps (1, 1, 2) | DENSE_RANK() |
| Bucket into N equal-sized groups | NTILE(n) |
| Compare against the previous row | LAG() |
| Compare against the next row | LEAD() |
| Get the first or last value in a group | FIRST_VALUE() / LAST_VALUE() |
| Express a row as a percentile | PERCENT_RANK() / CUME_DIST() |
| Find streaks or gaps in dates | ROW_NUMBER() + LAG() โ see Gap & Island |
ORDER BY for ranking/value functions โ results are non-deterministic. Fix: always include ORDER BY, with a tie-breaker if values can repeat.LAST_VALUE() returning the current row โ the default frame stops at CURRENT ROW. Fix: add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.WHERE โ window functions run after WHERE. Fix: wrap the query in a CTE or subquery and filter outside.RANK with DENSE_RANK โ RANK skips numbers after ties; DENSE_RANK doesnโt. Fix: pick based on whether downstream consumers expect contiguous ranks.ROW_NUMBER() changing across runs โ the engine breaks ties however it wants. Fix: add a stable secondary column to ORDER BY (e.g. an id).ROWS when you needed RANGE (or vice versa) โ different semantics with duplicate ORDER BY values. Fix: ROWS = exact row count, RANGE = value-based.PARTITION BY on huge tables โ sort spills kill performance. Fix: filter aggressively first; consider LATERAL joins for top-N at scale.Window function support is broadly consistent โ but the syntactic sugar differs. The Reference section has dedicated pages for each function in each database.
| Feature | PostgreSQL | MySQL 8+ | SQL Server | Snowflake / BigQuery |
|---|---|---|---|---|
| All standard window functions | โ | โ | โ | โ |
QUALIFY clause (filter window results) |
โ | โ | โ | โ (huge readability win) |
GROUPS frame |
โ (11+) | โ | โ | Snowflake โ / BigQuery โ |
EXCLUDE frame option |
โ (11+) | โ | โ | โ |
Named windows (WINDOW w AS (...)) |
โ | โ | โ | โ |
๐ For exact syntax in your dialect, browse the Reference section โ every function has a dedicated page per database (Postgres, MySQL, SQL Server, MariaDB, SQLite, Snowflake, BigQuery, and more).
Reference is good. Reps are better. Pick a row from the cheat sheet above, then come down here and solve the matching problem.
This cheat sheet is a quick reference. The deep dives live here:
Want to explore all window functions across every SQL dialect? Visit the full SQL Dialects & Window Functions Documentation.