SQL Window Functions Cheat Sheet
Covers ROW_NUMBER, RANK, LAG/LEAD, and aggregate window functions with OVER, PARTITION BY, and frame clauses for analytical SQL queries.
Ranking Functions
ROW_NUMBER, RANK, and DENSE_RANK compared.
SELECT employee, department, salary, ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rank, DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rankFROM employees;-- ROW_NUMBER: always unique, no ties (1,2,3,4)-- RANK: ties share a rank, next rank skips (1,2,2,4)-- DENSE_RANK: ties share a rank, no gap (1,2,2,3)
LAG / LEAD
Access a preceding or following row without a self-join.
SELECT order_date, revenue, LAG(revenue, 1) OVER (ORDER BY order_date) AS prev_day_revenue, LEAD(revenue, 1) OVER (ORDER BY order_date) AS next_day_revenue, revenue - LAG(revenue, 1) OVER (ORDER BY order_date) AS day_over_day_changeFROM daily_sales;-- LAG/LEAD read a value from a preceding/following row without a self-join
Running Totals & Frames
Aggregate window functions with explicit frame boundaries.
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 trailing_7day_avg, amount / SUM(amount) OVER () AS pct_of_totalFROM orders;
Key Concepts
Core building blocks of the window function syntax.
- OVER()- Turns an aggregate/ranking function into a window function that returns one row per input row instead of collapsing groups
- PARTITION BY- Splits rows into independent groups (like GROUP BY) but each group's rows are still returned individually
- ORDER BY (in OVER)- Defines the row order used for ranking, LAG/LEAD, and running calculations within each partition
- Frame clause (ROWS/RANGE)- Defines which rows relative to the current row are included, e.g., ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
- NTILE(n)- Divides partition rows into n roughly equal buckets, useful for quartiles/deciles
- FIRST_VALUE / LAST_VALUE- Returns the first or last value in the current window frame, subject to the frame boundaries
RANGE vs ROWS Frames
ROWS counts physical rows; RANGE groups logical peers by ORDER BY value.
-- ROWS: exactly the 2 preceding physical rows, regardless of value tiesSELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rows_sumFROM orders;-- RANGE: includes ALL rows sharing the current ORDER BY value as peers,-- not just a fixed row count -- ties expand the frameSELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW) AS range_sumFROM orders;-- Default frame when only ORDER BY is given (no ROWS/RANGE specified)-- is 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW' -- ties are-- included together, which surprises people expecting a running total-- row by row when the ORDER BY column has duplicate values.
The WINDOW Clause (Reusable Definitions)
Define a partition/order spec once and reference it across multiple window functions.
SELECT department, employee, salary, RANK() OVER w AS dept_rank, AVG(salary) OVER w AS dept_avg, salary - AVG(salary) OVER w AS diff_from_avgFROM employeesWINDOW w AS (PARTITION BY department ORDER BY salary DESC)ORDER BY department, dept_rank;-- Extending a named window with its own frameSELECT order_date, amount, SUM(amount) OVER w AS running_totalFROM ordersWINDOW w AS (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
PERCENT_RANK, CUME_DIST & NTILE Median
Statistical distribution functions for percentile-style analytics.
SELECT student, score, PERCENT_RANK() OVER (ORDER BY score) AS pct_rank, -- (rank-1)/(n-1), range [0,1] CUME_DIST() OVER (ORDER BY score) AS cume_dist, -- fraction of rows <= current NTILE(4) OVER (ORDER BY score) AS quartileFROM exam_results;-- Approximate median via PERCENTILE_CONT (ordered-set aggregate, not a-- ranking window function, but often used alongside them)SELECT department, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salaryFROM employeesGROUP BY department;
Gaps-and-Islands with Window Functions
Detect runs of consecutive values (e.g., consecutive login days) without a loop.
-- Trick: subtracting a ROW_NUMBER() from a sequential date collapses-- each consecutive run to a single constant "island" valueWITH numbered AS ( SELECT user_id, login_date, login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::int AS grp FROM daily_logins)SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_lengthFROM numberedGROUP BY user_id, grpORDER BY streak_length DESC;-- Every row in the same unbroken streak gets the same 'grp' value,-- because the gap between login_date and its row number stays constant.
Advanced Window Concepts
Terminology and tools for squeezing more out of window functions.
- Frame exclusion (EXCLUDE)- EXCLUDE CURRENT ROW / EXCLUDE TIES / EXCLUDE GROUP removes rows from an otherwise-matching frame, e.g., computing a peer average excluding the row itself
- Dedupe pattern- ROW_NUMBER() OVER (PARTITION BY dedupe_key ORDER BY updated_at DESC) = 1 filtered in an outer query is the standard way to pick 'latest row per key'
- QUALIFY clause- Snowflake/BigQuery/DuckDB extension that filters directly on a window function result without a wrapping CTE (not standard ANSI SQL, unavailable in Postgres/MySQL)
- Multiple window sorts- Window functions with different PARTITION BY/ORDER BY specs each require their own sort; aligning specs across functions in one query lets the planner reuse a single sort pass
- FILTER with window aggregates- Postgres allows FILTER (WHERE cond) on a window aggregate to conditionally include rows in the running calculation without a CASE expression
- Nested window functions- A window function's result can't be referenced by another window function in the same SELECT list directly; wrap the first in a CTE/subquery and apply the second in the outer query
Window functions run after WHERE, GROUP BY, and HAVING but before ORDER BY and LIMIT — so you can't filter on a window function's result in the same-level WHERE clause; wrap the query in a CTE or subquery and filter in the outer query instead.