datarekha

Window functions

The SQL feature that separates juniors from seniors — running totals, rankings, lag/lead — all without grouping the rows away.

10 min read Advanced SQL Lesson 13 of 27

What you'll learn

  • The mental model: aggregate without collapsing the rows
  • PARTITION BY, ORDER BY, and the frame
  • LAG and LEAD for previous/next-row comparisons
  • The top-N-per-group pattern and why it needs a wrapper query

Before you start

A normal aggregate like SUM collapses many rows into one. A window function computes an aggregate over a “window” of rows without collapsing — every input row still gets its own output row. That single difference is what makes running totals, rankings, and previous-row comparisons possible in one clean query.

You turn an aggregate into a window function by adding OVER (…), which has three pieces: PARTITION BY splits the rows into independent groups (like GROUP BY, but the rows survive); ORDER BY orders the rows within each partition; and the frame decides which rows around the current one the function actually reads. When you give an ORDER BY and no explicit frame, the default is “everything from the start of the partition through the current row” — which is exactly why SUM(...) OVER (ORDER BY date) gives a running total instead of a grand total.

That frame is the whole trick, and it is invisible in the SQL — so let us draw it. For one user’s three orders, the running total at each row sums the shaded frame:

frame: partition start → current row2024-01-05302024-01-129002024-01-2050← current rowrunning total30930980
The frame grows one row at a time, so the running total at row 2 is 30 + 900 = 930, and at row 3, 980.

A running total

SELECT order_date,
       revenue,
       SUM(revenue) OVER (ORDER BY order_date) AS running_total
FROM   orders;
order_daterevenuerunning_total
2024-01-053030
2024-01-12900930
2024-01-2050980

The same SUM you already know — but OVER (ORDER BY order_date) keeps every row and accumulates through time. Add PARTITION BY country and each country gets its own independent running total, still one row per order, still no GROUP BY.

LAG and LEAD — reach into the neighbouring row

LAG(col) hands you col from the previous row in the partition’s order; LEAD(col) from the next. Pair it with date math and you have gap analysis for free:

SELECT order_date,
       LAG(order_date) OVER (ORDER BY order_date) AS prev_order,
       julianday(order_date)
         - julianday(LAG(order_date) OVER (ORDER BY order_date)) AS days_since
FROM   orders;
order_dateprev_orderdays_since
2024-01-05NULLNULL
2024-01-122024-01-057
2024-01-202024-01-128

The first row has no previous, so LAG returns NULL. After that you get day-over-day deltas — the raw material of churn windows, session gaps, and “days since last purchase.”

Top-N per group — the wrap-and-filter idiom

The pattern you will reach for most is “the top few rows within each group.” You number the rows with ROW_NUMBER() OVER (PARTITION BY group ORDER BY value DESC) and then keep the low numbers — but a window function is evaluated after the WHERE of its own SELECT, so you cannot filter on it inline. You wrap the query and filter in the outer one:

SELECT * FROM (
  SELECT u.name, o.order_date,
         ROW_NUMBER() OVER (PARTITION BY u.id ORDER BY o.order_date DESC) AS rn
  FROM   orders o
  JOIN   users u ON u.id = o.user_id
) t
WHERE rn <= 2;          -- top 2 most recent orders per user
nameorder_datern
Asha2024-01-201
Asha2024-01-122
Carlos2024-01-081

Asha’s two most recent orders survive, her oldest is dropped, and Carlos — with a single order — keeps it. That wrap-and-filter shape is the canonical top-N-per-group query, and you will write it constantly. (The ranking lesson goes deep on ROW_NUMBER vs RANK vs DENSE_RANK for the ties.)

Practice

Quick check

0/3
Q1What's the difference between a window function and an aggregate?
Q2Why does SUM(revenue) OVER (ORDER BY date) give a running total, not a grand total?
Q3You want the top 3 customers per region. Cleanest approach?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

FAQCommon questions

Questions about this lesson

What does OVER (PARTITION BY ...) do?

`PARTITION BY` splits the rows into groups, and the window function is computed separately within each group while still returning every row. Without `PARTITION BY`, the window spans the entire result set.

What's the difference between ROW_NUMBER, RANK, and DENSE_RANK?

All number rows within a partition but handle ties differently: `ROW_NUMBER` gives every row a unique number; `RANK` gives ties the same number then skips the following values; `DENSE_RANK` gives ties the same number without skipping.

Can I filter on a window function in a WHERE clause?

No — window functions are computed after `WHERE`, so you can't reference them there. Wrap the query in a subquery or CTE and filter on the window column in the outer query — the standard pattern for 'top N per group'.

Practice this in an interview

All questions
How do FIRST_VALUE and LAST_VALUE work, and why does LAST_VALUE often return unexpected results?

FIRST_VALUE returns the value from the first row of the window frame; LAST_VALUE returns the value from the last row. LAST_VALUE surprises most users because the default frame ends at CURRENT ROW, not at the end of the partition — the frame must be explicitly extended to ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to reach the true last row.

How do LAG and LEAD work, and how would you use them to compute month-over-month revenue change?

LAG accesses a value from a previous row within the partition; LEAD accesses a value from a following row. Both accept an optional offset (default 1) and a default value when the referenced row does not exist. They are the standard tool for period-over-period comparisons without a self-join.

How would you calculate a 7-day moving average of daily sales, and what frame clause is needed?

Use AVG() with a ROWS frame specifying 6 PRECEDING to current row — this captures exactly 7 physical rows regardless of date gaps. RANGE with INTERVAL '6 days' PRECEDING is the alternative when you need a true calendar window, but it requires at most one row per date and may include fewer than 7 rows if days are missing.

What is the difference between PARTITION BY in a window function and GROUP BY in an aggregate query?

GROUP BY collapses multiple rows into one row per group and discards individual row data. PARTITION BY divides rows into groups for the window function calculation but preserves every row in the output — each row retains its own columns plus the computed window value.

What is the difference between ROWS and RANGE in a window frame clause, and when does it matter?

ROWS defines the frame by physical row positions relative to the current row; RANGE defines it by logical value distance on the ORDER BY column, grouping all rows with equal values as peers. The difference only matters when the ORDER BY column has duplicate values — RANGE may silently include extra peer rows in aggregations while ROWS is always precise.

How do you compute a running total (cumulative sum) using a window function, and what frame clause does it use by default?

Use SUM() OVER (ORDER BY ...) — the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This works correctly for strictly increasing order columns, but silently over-counts when multiple rows share the same ORDER BY value because the RANGE default includes all peers.

How do you find the second-highest salary in SQL?

Rank salaries with DENSE_RANK() ordered descending and keep rank 2 — it handles duplicate salaries and generalises to the Nth-highest. A correlated subquery (the max salary strictly below the overall max) also works, while LIMIT 1 OFFSET 1 is only safe when no two people share a salary.

Why can't you use a window function directly in a WHERE clause, and how do you work around it?

Window functions are evaluated in the SELECT phase, after WHERE and HAVING have already filtered rows. Referencing a window function alias in WHERE causes a syntax or evaluation-order error. The fix is to wrap the query in a CTE or subquery so the outer query can filter on the computed window value.

Related lessons

Explore further

Skip to content