Skip to main content
SQL Interviews intermediate Lesson 4 of 10

Window Functions

ROW_NUMBER, RANK and DENSE_RANK on the same ties, running totals, LAG and LEAD, and the frame clause that silently changes your answer.

Window functions keep every row and add a column that can see the neighbours. Almost every interview question phrased as “per customer, the …” is one.

The three ranking functions on the same data

SELECT name, amount,
       row_number() OVER (ORDER BY amount DESC) AS rn,
       rank()       OVER (ORDER BY amount DESC) AS rnk,
       dense_rank() OVER (ORDER BY amount DESC) AS dense
FROM (VALUES ('a', 300), ('b', 200), ('c', 200), ('d', 100)) AS t(name, amount);
$ duckdb interview.duckdb < ranks.sql
┌─────────┬────────┬───────┬───────┬───────┐
│  name   │ amount │  rn   │  rnk  │ dense │
│ varchar │ int32  │ int64 │ int64 │ int64 │
├─────────┼────────┼───────┼───────┼───────┤
│ a       │    300 │     1 │     1 │     1 │
│ b       │    200 │     2 │     2 │     2 │
│ c       │    200 │     3 │     2 │     2 │
│ d       │    100 │     4 │     4 │     3 │

Rows b and c are tied at 200, and that single fact separates the three:

row_number  1 2 3 4    ties broken arbitrarily — a different run may swap b and c
rank        1 2 2 4    ties share, then the next value skips to 4
dense_rank  1 2 2 3    ties share, no gap

“Which one depends on ties. If two customers tie for second, should the next be third or fourth? Should both tied customers appear in a top-2? ROW_NUMBER picks one arbitrarily, which is fine for pagination and wrong for ‘the top 2 spenders’.”

That question, asked before writing, is worth more than the query.

ROW_NUMBER without a deterministic ORDER BY is non-deterministic. Add a tiebreaker — ORDER BY amount DESC, order_id — whenever the result must be reproducible.

Partitions: the same computation, per group

SELECT c.name, o.order_id, o.amount,
       row_number() OVER (PARTITION BY c.customer_id ORDER BY o.order_date) AS nth_order,
       sum(o.amount) OVER (PARTITION BY c.customer_id)                      AS customer_total,
       round(100.0 * o.amount /
             sum(o.amount) OVER (PARTITION BY c.customer_id), 1)            AS pct_of_customer
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.name, o.order_date;
┌─────────┬──────────┬────────┬───────────┬────────────────┬─────────────────┐
│  name   │ order_id │ amount │ nth_order │ customer_total │ pct_of_customer │
├─────────┼──────────┼────────┼───────────┼────────────────┼─────────────────┤
│ Ana     │      101 │ 120.00 │         1 │         400.50 │            30.0 │
│ Ana     │      102 │  80.50 │         2 │         400.50 │            20.1 │
│ Ana     │      105 │ 200.00 │         3 │         400.50 │            49.9 │
│ Bo      │      103 │ 310.00 │         1 │         409.99 │            75.6 │
│ Bo      │      106 │  99.99 │         2 │         409.99 │            24.4 │
│ Cy      │      104 │  45.00 │         1 │          45.00 │           100.0 │
│ Di      │      107 │ 150.00 │         1 │         150.00 │           100.0 │
└─────────┴──────────┴────────┴───────────┴────────────────┴─────────────────┘

Seven order rows in, seven rows out. GROUP BY customer_id would have returned four. That is the whole distinction, and it is the sentence to say when asked why you chose a window function.

Note sum(...) OVER (PARTITION BY ...) with no ORDER BY — that gives the partition total on every row, which is what a percentage-of-total needs. Adding an ORDER BY turns it into a running total, which is the next section and a very easy accident.

Running totals, and the frame that changes the answer

SELECT order_date, amount,
       sum(amount) OVER (ORDER BY order_date)                                  AS default_frame,
       sum(amount) OVER (ORDER BY order_date
                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)     AS rows_frame,
       sum(amount) OVER (ORDER BY order_date
                         RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)    AS range_frame
FROM (VALUES (DATE '2024-01-01', 10), (DATE '2024-01-02', 20),
             (DATE '2024-01-02', 30), (DATE '2024-01-03', 40)) AS t(order_date, amount)
ORDER BY order_date;
┌────────────┬────────┬───────────────┬────────────┬─────────────┐
│ order_date │ amount │ default_frame │ rows_frame │ range_frame │
├────────────┼────────┼───────────────┼────────────┼─────────────┤
│ 2024-01-01 │     10 │            10 │         10 │          10 │
│ 2024-01-02 │     20 │            60 │         30 │          60 │
│ 2024-01-02 │     30 │            60 │         60 │          60 │
│ 2024-01-03 │     40 │           100 │        100 │         100 │
└────────────┴────────┴───────────────┴────────────┴─────────────┘

The two rows on 2024-01-02 both show 60 under the default frame, not 30 and 60.

ORDER BY with no explicit frame defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE treats all rows with the same ORDER BY value as one peer group — so both rows see the full day. ROWS counts rows literally and gives the row-by-row running total most people mean.

“If the ORDER BY column has duplicates, RANGE and ROWS give different answers. I write ROWS explicitly when I want a row-by-row running total, and RANGE when the ordering column is a date and I want the whole day to settle together.”

That distinction appears rarely in tutorials and frequently in production bugs.

The moving-average frame

SELECT order_date, amount,
       round(avg(amount) OVER (ORDER BY order_date
                               ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS trailing_3,
       sum(amount) OVER (ORDER BY order_date
                         ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)   AS remaining
FROM orders
WHERE status = 'shipped'
ORDER BY order_date;
┌────────────┬────────┬────────────┬───────────┐
│ order_date │ amount │ trailing_3 │ remaining │
├────────────┼────────┼────────────┼───────────┤
│ 2024-03-01 │ 120.00 │     120.00 │    935.50 │
│ 2024-03-15 │  80.50 │     100.25 │    815.50 │
│ 2024-03-18 │ 310.00 │     170.17 │    735.00 │
│ 2024-04-20 │ 200.00 │     196.83 │    425.00 │
│ 2024-05-30 │ 150.00 │     220.00 │    225.00 │
│ 2024-06-02 │  75.00 │     141.67 │     75.00 │
└────────────┴────────┴────────────┴───────────┘

The first two trailing_3 values average fewer than three rows — the frame is clipped at the partition edge rather than returning NULL. If the question wants NULL until three rows exist, that must be written explicitly with a count(*) OVER (...) guard. Ask which is wanted.

Order 108 (75.00, NULL customer) is present here because this query does not join to customers. The same query with a join would silently lose it — a good thing to notice aloud.

LAG and LEAD: comparing to the neighbour

SELECT c.name, o.order_date, o.amount,
       lag(o.amount)     OVER (PARTITION BY c.customer_id ORDER BY o.order_date) AS prev_amount,
       o.amount - lag(o.amount) OVER (PARTITION BY c.customer_id ORDER BY o.order_date) AS delta,
       o.order_date - lag(o.order_date) OVER (PARTITION BY c.customer_id ORDER BY o.order_date)
                                                                                  AS days_since,
       lead(o.order_date) OVER (PARTITION BY c.customer_id ORDER BY o.order_date)  AS next_order
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.name, o.order_date;
┌─────────┬────────────┬────────┬─────────────┬─────────┬────────────┬────────────┐
│  name   │ order_date │ amount │ prev_amount │  delta  │ days_since │ next_order │
├─────────┼────────────┼────────┼─────────────┼─────────┼────────────┼────────────┤
│ Ana     │ 2024-03-01 │ 120.00 │        NULL │    NULL │       NULL │ 2024-03-15 │
│ Ana     │ 2024-03-15 │  80.50 │      120.00 │  -39.50 │         14 │ 2024-04-20 │
│ Ana     │ 2024-04-20 │ 200.00 │       80.50 │  119.50 │         36 │       NULL │
│ Bo      │ 2024-03-18 │ 310.00 │        NULL │    NULL │       NULL │ 2024-05-05 │
│ Bo      │ 2024-05-05 │  99.99 │      310.00 │ -210.01 │         48 │       NULL │
│ Cy      │ 2024-04-02 │  45.00 │        NULL │    NULL │       NULL │       NULL │
│ Di      │ 2024-05-30 │ 150.00 │        NULL │    NULL │       NULL │       NULL │
└─────────┴────────────┴────────┴─────────────┴─────────┴────────────┴────────────┘

The first row of every partition has a NULL prev_amount — correctly, because there is no previous order. If the question wants zero instead, lag(o.amount, 1, 0) supplies a default.

days_since is the shape behind a large family of questions: time between events, session gaps, churn detection, and the streak problems in a later lesson.

Window functions cannot go in WHERE

SELECT name, amount, row_number() OVER (ORDER BY amount DESC) AS rn
FROM orders o JOIN customers c USING (customer_id)
WHERE rn <= 3;
Binder Error: Referenced column "rn" not found in FROM clause!

Window functions are evaluated after WHERE, GROUP BY and HAVING — they are computed as part of SELECT. The fix is a CTE:

WITH ranked AS (
    SELECT c.name, o.amount,
           row_number() OVER (ORDER BY o.amount DESC, o.order_id) AS rn
    FROM orders o JOIN customers c USING (customer_id)
)
SELECT * FROM ranked WHERE rn <= 3;
┌─────────┬────────┬───────┐
│  name   │ amount │  rn   │
├─────────┼────────┼───────┤
│ Bo      │ 310.00 │     1 │
│ Ana     │ 200.00 │     2 │
│ Di      │ 150.00 │     3 │
└─────────┴────────┴───────┘

This two-step shape — compute the window in a CTE, filter in the outer query — is the answer to the entire top-N-per-group family, which the next lesson is about.

Reusing a window definition

SELECT c.name, o.amount,
       row_number() OVER w AS rn,
       sum(o.amount) OVER w AS running,
       lag(o.amount) OVER w AS prev
FROM customers c JOIN orders o USING (customer_id)
WINDOW w AS (PARTITION BY c.customer_id ORDER BY o.order_date)
ORDER BY c.name, o.order_date;
┌─────────┬────────┬───────┬─────────┬────────┐
│  name   │ amount │  rn   │ running │  prev  │
├─────────┼────────┼───────┼─────────┼────────┤
│ Ana     │ 120.00 │     1 │  120.00 │   NULL │
│ Ana     │  80.50 │     2 │  200.50 │ 120.00 │
│ Ana     │ 200.00 │     3 │  400.50 │  80.50 │
│ Bo      │ 310.00 │     1 │  310.00 │   NULL │
│ Bo      │  99.99 │     2 │  409.99 │ 310.00 │
│ Cy      │  45.00 │     1 │   45.00 │   NULL │
│ Di      │ 150.00 │     1 │  150.00 │   NULL │
└─────────┴────────┴───────┴─────────┴────────┘

The WINDOW clause names a definition once. With three or more windows sharing a partition it removes the copy-paste that hides a typo — and typing the same OVER clause four times is a real source of “why is only one column wrong”.

Supported in PostgreSQL, DuckDB, SQL Server and MySQL 8; not in older MySQL or SQLite before 3.28.

Recognising it

QUESTION                                        FUNCTION
"nth per group", "top N per group"              row_number() in a CTE, filter outside
"rank, and ties should share"                   rank() or dense_rank() — ask which
"running total", "cumulative"                   sum() OVER (ORDER BY ... ROWS ...)
"percentage of the group total"                 x / sum(x) OVER (PARTITION BY ...)
"compare to the previous / next row"            lag() / lead()
"days between consecutive events"               order_date - lag(order_date)
"moving average over N periods"                 avg() OVER (ROWS BETWEEN N-1 PRECEDING ...)
"first / last value in the group"               first_value() / last_value() — mind the frame
"which decile / quartile"                       ntile(4)
one row per group, nothing per-row              GROUP BY, not a window

Practice

1. Rank tied values three ways.
row_number  1 2 3 4      rank  1 2 2 4      dense_rank  1 2 2 3

Ask what ties should do before choosing. ROW_NUMBER also picks arbitrarily unless the ORDER BY is deterministic.

2. Running-total with the default frame over a column with duplicates.
default (RANGE): 10, 60, 60, 100      explicit ROWS: 10, 30, 60, 100

RANGE includes all peer rows with the same ORDER BY value. Write ROWS when you mean row by row.

3. Filter on a window column in WHERE.
Binder Error: Referenced column "rn" not found in FROM clause!

Windows are computed as part of SELECT, after WHERE. Wrap in a CTE and filter outside.

4. Add a percentage-of-total column with ORDER BY inside the OVER clause.
You get percentage of the running total, not of the group total.

sum(x) OVER (PARTITION BY g) with no ORDER BY is the partition total. Adding ORDER BY makes it cumulative — a very easy accident.

Next: top-N-per-group — the single most-asked SQL interview pattern, three ways.

Frequently Asked Questions

What is the difference between ROW_NUMBER, RANK and DENSE_RANK?
On ties, ROW_NUMBER gives 1,2,3,4 and picks an arbitrary winner; RANK gives 1,1,3,4 and skips numbers after a tie; DENSE_RANK gives 1,1,2,3 and does not skip. Which is right depends entirely on what ties should do, which is the clarifying question to ask before writing.
How is a window function different from GROUP BY?
GROUP BY collapses rows — one row out per group. A window function keeps every row and adds a computed column that can see the other rows in its partition. Whenever a question wants a per-row value alongside a group-level figure, it is a window function.
What does the frame clause do?
It limits which rows in the partition the function sees. The default when you write ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which gives a running total — but with RANGE it includes all peer rows with the same ORDER BY value, so duplicates jump ahead. ROWS is the version that counts rows literally.
Can I use a window function in WHERE?
No — window functions run after WHERE, so the column does not exist yet. Wrap the query in a CTE or subquery and filter in the outer level. That two-step shape is the standard answer to every top-N-per-group question.