SQL Window Functions
Window functions compute a value across a set of rows related to the current row without collapsing them, so you can rank, compare to a neighbor, or run a cumulative total while keeping every row. They are how analysts answer 'compared to what?' questions in pure SQL, and most interviewers use them to tell people who know SQL from people who know GROUP BY.
Transcript
Find the top order per customer. Now do it without dropping the other rows. That follow-up is exactly the case GROUP BY can't handle and a window function can, and it's why most interviewers use window functions to tell the people who know SQL from the people who know GROUP BY.
GROUP BY answers one number per group and throws the detail rows away. Window functions ask something else. For each row, what does this group look like around it? And they keep the row. That single property, no collapsing, is what unlocks ranking, row-to-row comparison and running totals, all in one pass over the data.
You attach an OVER clause. It has three knobs. PARTITION BY splits rows into independent groups, like a GROUP BY that doesn't collapse. ORDER BY orders rows inside each partition, which is what makes previous row and running total mean anything, because without an order inside the partition there's no such thing as before. The frame bounds how many rows the function sees. Then the result is attached to the row.
The three ranking functions trip people up. Take 100, 100, 90. ROW_NUMBER gives 1, 2, 3 with an arbitrary tie-break, so it's always unique. RANK gives 1, 1, 3, because ties share a rank and then it skips. DENSE_RANK gives 1, 1, 2. No gap. So reach for ROW_NUMBER when you want the single latest row per customer, and RANK or DENSE_RANK when you're building a leaderboard.
Here's a running total of each customer's spend. SUM over amount, partitioned by customer, ordered by date. Then the part people skip: an explicit frame, rows between unbounded preceding and the current row. Leave it out and you get a different default, which the next table shows on six executed rows.
Six orders. Customer A has two on the same day. With ROWS, the second row reads 160, the total after two orders. The default RANGE frame says 200. Both orders on the ninth share the ordering key, so RANGE treats them as one bucket, and the 60 row already includes the 40. On a daily sales report that's a total reported a day early, and nobody gets an error.
Here's one more trap. You can't filter on a window result in WHERE, because window functions are evaluated after WHERE. To keep only rank one, wrap the query in a CTE or a subquery and filter at the outer level. And ORDER BY inside OVER only orders rows for the calculation, so your output still needs its own ORDER BY at the top level.
In the interview, say it plainly. GROUP BY collapses, windows keep every row. And for a running total, spell out ROWS, because the default lumps tied order keys together. The six-row example is on fdeinterviews.com.
TL;DR: A window function runs a calculation over a window of rows defined by
OVER (PARTITION BY ... ORDER BY ...)while keeping every input row in the output. That single property, no collapsing, is what separates it fromGROUP BYand unlocks ranking, row-to-row comparison, and running totals in one pass.
The idea
Read step 6 against what GROUP BY would do at the same point. That single difference, attaching instead of collapsing, is the whole reason window functions exist.
GROUP BY answers "one number per group" and throws the detail rows away. A window function answers "for each row, what does this group look like around it" and keeps the row. You attach an OVER clause to an aggregate or a ranking function, and SQL evaluates that function against a slice of the result set instead of the whole table.
The OVER clause has three knobs. PARTITION BY splits rows into independent groups (like a GROUP BY that does not collapse). ORDER BY orders rows inside each partition, which is what makes "previous row" and "running total" meaningful. The frame (ROWS BETWEEN ...) bounds how many rows the function sees relative to the current one.
RANK() OVER (PARTITION BY dept ORDER BY salary DESC)The three ranking functions trip people up. Given values 100, 100, 90: ROW_NUMBER gives 1, 2, 3 (arbitrary tie-break, always unique). RANK gives 1, 1, 3 (ties share a rank, then it skips). DENSE_RANK gives 1, 1, 2 (ties share, no gap). Pick ROW_NUMBER for "give me the single latest row per customer", RANK/DENSE_RANK for leaderboards.
LAG and LEAD pull a value from a row N positions back or ahead in the partition, the clean way to compute deltas (today versus yesterday) without a self-join.
A worked example
Rank each customer's orders by amount, and carry a running total of their spend over time.
SELECT
customer_id,
order_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id ORDER BY amount DESC
) AS amount_rank,
SUM(amount) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
amount - LAG(amount) OVER (
PARTITION BY customer_id ORDER BY order_date
) AS delta_vs_prev
FROM orders;
Every original order row survives. amount_rank orders within the customer by size; running_total accumulates by date; delta_vs_prev is null on a customer's first order because there is no prior row. Note the explicit frame on the running total: with ORDER BY and no frame, the default is RANGE UNBOUNDED PRECEDING AND CURRENT ROW, which treats ties in order_date as one bucket and can quietly inflate the total. Spelling out ROWS is the safer habit. Swap to ROWS BETWEEN 6 PRECEDING AND CURRENT ROW and the same SUM becomes a trailing 7-row moving average when you divide by the count.
Run it on six rows and the frame warning stops being abstract. Customer A has two orders on the same day, which is exactly the tie that separates ROWS from RANGE:
CREATE TABLE orders (customer_id TEXT, order_date TEXT, amount INT);
INSERT INTO orders VALUES
('A','2026-01-05',100), ('A','2026-01-09',60), ('A','2026-01-09',40), ('A','2026-01-20',120),
('B','2026-01-03',70), ('B','2026-01-11',70);
Adding one more column to the query above, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS default_frame_total, with no frame spelled out, gives this on SQLite (executed):
| customer | order_date | amount | amount_rank | running_total (ROWS) | default_frame_total (RANGE) | delta_vs_prev |
|---|---|---|---|---|---|---|
| A | 2026-01-05 | 100 | 2 | 100 | 100 | null |
| A | 2026-01-09 | 60 | 3 | 160 | 200 | -40 |
| A | 2026-01-09 | 40 | 4 | 200 | 200 | -20 |
| A | 2026-01-20 | 120 | 1 | 320 | 320 | 80 |
| B | 2026-01-03 | 70 | 1 | 70 | 70 | null |
| B | 2026-01-11 | 70 | 2 | 140 | 140 | 0 |
Read the second row. The explicit ROWS frame gives 160, the running total after two orders. The default RANGE frame gives 200, because both orders on 2026-01-09 share the ordering key and RANGE treats them as one bucket, so the 60 row already includes the 40. On a daily sales report that is a total reported a day early, and nobody gets an error. One more thing the tie exposes: delta_vs_prev on those two rows depends on which of them SQL happens to visit first, because ORDER BY order_date alone does not decide it. Add a tiebreaker (ORDER BY order_date, order_id) whenever LAG or a running total must be reproducible.
Why interviewers probe this
It is the fastest read on real SQL fluency. Someone who reaches for a correlated subquery or a self-join to find "the top order per customer" is telling you they have not used a window function in anger. The follow-up is usually "now do it without dropping the other rows", which is exactly the case GROUP BY cannot handle and a window can. A second probe: "what is the difference between RANK and DENSE_RANK, and which one for a top-3-with-ties report?" If you say RANK, you may return more than three rows when ties straddle the cutoff, so the honest answer names the trade.
Common misconceptions
- "A window function is just a fancy
GROUP BY." They compute differently:GROUP BYcollapses, windows preserve. You can use both in one query, but the window runs after theGROUP BYandWHERE, on the already-grouped rows. - "I can filter on a window result in
WHERE." You cannot. Window functions are evaluated afterWHERE, so to filter onamount_rank = 1you wrap the query in a CTE or subquery and filter on the outer level. - "
ORDER BYinOVERsorts my output." It only orders rows inside the window for the calculation. Your final row order still needs a top-levelORDER BY. - "Leaving out the frame is harmless." With an
ORDER BYpresent, the default frame isRANGE, notROWS, and it lumps tied order keys together. For running totals, stateROWSexplicitly.
Key takeaways
OVER (PARTITION BY ... ORDER BY ...)computes per-window values while keeping every row; that is the whole point versusGROUP BY.ROW_NUMBERis always unique,RANKskips after ties,DENSE_RANKdoes not; choose by whether you need uniqueness or true ranking.LAG/LEADreplace self-joins for row-to-row deltas; running totals and moving averages come from an explicitROWS BETWEENframe.- You cannot filter a window result in
WHERE; push it to an outer query or CTE.
Related course lessons
- Declare the grain and prove that the totals survive joins →
30 min · Free with sign-in
Foundations of Forward Deployed Engineering · syllabus - Preserve valid history and the history of what you knew →
30 min · Premium
Foundations of Forward Deployed Engineering · syllabus - The database remembers what the meeting does not →
30 min · Premium
Hard Deployments · syllabus
