Window Functions Are SQL's Most Underused Feature. Five Patterns Cover Nearly Everything.
Part 1 of The Practical Data Analyst. ROW_NUMBER, RANK, LAG, running totals, NTILE: the five window-function patterns behind most analytics SQL, each with runnable code and verified output, plus a 200,000-row timing test against the self-join habit and the RANGE-versus-ROWS trap most tutorials skip.

There is a class of analytics questions that GROUP BY cannot answer without contortions: the latest event per user, the top three products in each region, this month against last month. The standard workaround analysts learn first is the self-join. The better tool has shipped in every major database for well over a decade. Window functions are not advanced SQL. They are the part of SQL most working analysts never got taught, and five patterns cover nearly every use you will meet.
A window function has three moving parts. The function itself (ROW_NUMBER, RANK, SUM, AVG, LAG). An OVER clause that defines the window: PARTITION BY slices rows into groups the way GROUP BY does, except the rows are not collapsed, so every row keeps its own result. And an optional frame clause that says which rows inside the partition count, for example the current row and everything before it. That last part is where the bugs live, and we will get to it.
Pattern 1: ROW_NUMBER() dedupes without a join
Keep the latest event per user. The classic answer joins the table to an aggregated subquery on the max timestamp. The window version numbers the rows inside each user's partition and keeps row one:
SELECT user_id, event, event_ts
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY event_ts DESC
) AS rn
FROM events
)
WHERE rn = 1;
Output:
| user_id | event | event_ts |
|---|---|---|
| 1 | purchase | 2026-01-03 |
| 2 | purchase | 2026-01-05 |
One honest edge: if two events share the same timestamp, ROW_NUMBER picks one arbitrarily. Add a second ORDER BY key (an id, a sequence) to make the tie-break deterministic, or switch to RANK() if ties should all survive. Say which behavior you want; do not let the engine decide silently.
Pattern 2: RANK() does top-N per group, and mind the ties
Top two products by revenue in each region:
SELECT region, product, revenue
FROM (
SELECT *,
RANK() OVER (
PARTITION BY region
ORDER BY revenue DESC
) AS rnk
FROM product_revenue
)
WHERE rnk <= 2;
Output:
| region | product | revenue |
|---|---|---|
| E | y | 400 |
| E | x | 50 |
| W | b | 300 |
| W | d | 300 |
RANK, DENSE_RANK, and ROW_NUMBER handle ties differently, and the difference decides whether your "top 10" has 10 rows. On the West region above:
| function | b (300) | d (300) | c (200) | a (100) |
|---|---|---|---|---|
| RANK() | 1 | 1 | 3 | 4 |
| DENSE_RANK() | 1 | 1 | 2 | 3 |
| ROW_NUMBER() | 1 | 2 | 3 | 4 |
RANK skips numbers after a tie. DENSE_RANK does not. ROW_NUMBER breaks ties arbitrarily and never shares a rank, which makes it the wrong tool whenever ties must be treated equally.
Pattern 3: LAG() compares periods without self-joins
Month-over-month revenue change. The self-join version matches each month to the previous one on a date offset. The window version just looks back one row:
WITH base AS (
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT month, revenue, prev_revenue,
ROUND(100.0 * (revenue - prev_revenue) / prev_revenue, 1) AS pct_change
FROM base;
Output:
| month | revenue | prev_revenue | pct_change |
|---|---|---|---|
| 2026-01 | 100 | NULL | NULL |
| 2026-02 | 120 | 100 | 20.0 |
| 2026-03 | 90 | 120 | -25.0 |
Two details worth knowing. The CTE exists because most engines will not let you reference the prev_revenue alias in the same SELECT where it is defined; repeating the LAG expression three times works but reads badly. And the first row is NULL by construction, so filter it or wrap it in COALESCE depending on whether your downstream tooling tolerates NULLs. LEAD() is the mirror image: it looks forward instead of back.
Pattern 4: running totals, and the frame trap nobody warns you about
Cumulative revenue over time:
SELECT order_date, amount,
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
The frame clause is the part tutorials skip, and skipping it is a real bug source. When ORDER BY is present and you write no frame, the default is not "all rows so far". It is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE treats rows that tie on the ORDER BY key as peers: they all get the same result. With two orders on 2026-01-01, the default gives:
| order_date | amount | RANGE default | ROWS explicit |
|---|---|---|---|
| 2026-01-01 | 10 | 30 | 10 |
| 2026-01-01 | 20 | 30 | 30 |
| 2026-01-02 | 40 | 70 | 70 |
Both 2026-01-01 rows report 30 under RANGE because the frame includes the whole peer group. ROWS counts physical rows and gives the running total you actually wanted. If your ORDER BY key is unique (a timestamp with time, a sequence id), RANGE and ROWS agree and the default is harmless. If it can repeat, dates very much included, write ROWS explicitly every time.
Pattern 5: moving averages and NTILE() bucketing
A 3-day moving average for smoothing noisy daily sales:
SELECT day, amount,
ROUND(AVG(amount) OVER (
ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS ma_3day
FROM daily_sales;
Output: 10.0, 15.0, 20.0, 30.0. Note the first two rows average fewer than three points. That is fine for smoothing, but say so in a footnote if the numbers go into a report, because the early averages are noisier by construction.
NTILE splits rows into roughly equal buckets. Quartiles by spend:
SELECT customer_id, total_spend,
NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_quartile
FROM customer_spend;
The two biggest spenders land in quartile 1, the two smallest in quartile 4. NTILE distributes remainders to the earliest buckets when rows do not divide evenly, so with 10 rows and 4 buckets the first two buckets get 3 rows each. Good enough for decile analysis and RFM-style segmentation; not a substitute for exact percentile cutoffs, where PERCENT_RANK() plus explicit thresholds is the honest tool.
Why not just self-join? A real timing test
I ran the running-total query three ways against 200,000 synthetic order rows in SQLite 3.45.1, in memory, median of three runs each:
| approach | 5,000 rows | 200,000 rows |
|---|---|---|
| window function | 0.010s | 0.46s |
| correlated subquery | 2.81s | not attempted |
| self-join | 5.92s | could not finish |
The self-join and the correlated subquery are quadratic: every row gets compared against every earlier row. On just 5,000 rows the self-join was already about 590 times slower than the window function, and the 20,000-row attempt exhausted temp disk before finishing, so there is no 200,000-row number for it. The window function sorts once and sweeps once. That is the entire performance story: one sort versus a nested loop. On a real warehouse the absolute numbers change and the optimizer may rescue some cases, but the asymptotic gap does not.
The verdict
Window functions replace whole families of self-joins with queries that are shorter, faster by orders of magnitude, and easier to read once you know the three moving parts. The learning investment is one afternoon: the function, the OVER clause, the frame. The frame clause is the part to actually study, because the RANGE default is the only genuinely surprising behavior in the five patterns above, and it bites exactly on the date columns analysts use most.
Key takeaways
- Five patterns cover most analytics SQL: ROW_NUMBER for dedupe, RANK for top-N per group, LAG/LEAD for period comparisons, SUM/AVG OVER with frames for running totals and moving averages, NTILE for bucketing.
- PARTITION BY groups rows without collapsing them, which is what makes per-row results like "share of region total" a one-liner.
- RANK skips numbers after ties, DENSE_RANK does not, ROW_NUMBER breaks ties arbitrarily. Match the function to the question.
- Always write the frame clause explicitly. The RANGE default groups tied ORDER BY values, which silently inflates running totals on duplicate dates.
- The performance gap is structural, not marginal: 0.46s versus a self-join that could not finish, because one sort beats a nested loop at any scale that matters.
Which of the five would have saved you the most time last month: the dedupe, the top-N, the period comparison, the running total, or the bucketing? Tell me which one your team still does with a self-join.
Related on Everyday Data Science: I Benchmarked Pandas, Polars, and DuckDB on 3.4M Taxi Rows, for when the question is which engine to run the query on, not just how to write it.
Sources: PostgreSQL window function tutorial, SQLite window function documentation. All query outputs and timings were produced by running the code in this article (SQLite 3.45.1); the syntax shown is standard and runs as written on PostgreSQL, BigQuery, Snowflake, and DuckDB.
About the writer
Data Scientist & AI Researcher
Data scientist and AI researcher at Pace University. I coined Artificial Frictional Unemployment, and built the first machine learning model for crop yield prediction in Sierra Leone. Author of Understanding Agentic AI. I write about agentic systems and applied ML, with a bias toward what actually works, and who gets left out when it doesn't.
Found this useful? Passing it on to someone who builds is the best way to help the publication grow.
Built something worth sharing? Write it up for us →