Window functions

An aggregate that hands the rows back to you: every row survives, but now it knows something about its group.

The idea

GROUP BY answers a question about a group and then throws the group away: seven orders in, two rows out. A window function asks the same kind of question but leaves every row standing. OVER (PARTITION BY region ORDER BY day) draws a window around each row's group, computes inside it, and writes the answer into a new column beside the row: seven rows in, seven rows out, just wider.

The only genuinely tricky part is deciding which rows are inside the window for a given row. That is what ORDER BY and the frame clause quietly control, and it is where most surprises live.

See it run

SELECT order_id, region, day, amount,
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY day) AS pos,
  SUM(amount)  OVER (PARTITION BY region ORDER BY day
                     RANGE BETWEEN UNBOUNDED PRECEDING
                                   AND CURRENT ROW)  AS run
FROM orders;

green band = north, warm band = south · the two tue rows are tied on the ORDER BY key

function
frame

How it works

The engine does three things, in order. It partitions the rows, sorts each partition by the window's ORDER BY, then walks each partition once, evaluating the function for every row against that row's frame.

Ranking functions ignore the frame entirely: they only look at the sort key. ROW_NUMBER counts positions; RANK is how many rows sort strictly before me, plus one; DENSE_RANK is how many distinct values I have seen, including mine. Aggregates like SUM do use the frame, and the default when you write ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which means every row whose sort value is ≤ mine, peers included.

SELECT
  order_id, region, day, amount,
  ROW_NUMBER() OVER w                      AS rn,
  RANK()       OVER w                      AS rnk,
  DENSE_RANK() OVER w                      AS drnk,
  SUM(amount)  OVER (w ROWS BETWEEN UNBOUNDED PRECEDING
                                AND CURRENT ROW)  AS run_rows,
  SUM(amount)  OVER w                      AS run_range   -- implicit RANGE
FROM orders
WINDOW w AS (PARTITION BY region ORDER BY day)
ORDER BY region, day, order_id;   -- the output order is a separate decision

The named WINDOW clause works in PostgreSQL, MySQL 8, SQLite 3.28+ and DuckDB; elsewhere just repeat the OVER (...) text. Here is the same semantics as plain Python, which makes the frame difference hard to miss:

def windowed(rows):
    out = []
    for region in dict.fromkeys(r["region"] for r in rows):        # PARTITION BY
        part = sorted((r for r in rows if r["region"] == region),
                      key=lambda r: r["day"])                      # ORDER BY
        for i, row in enumerate(part):
            peers = [j for j, x in enumerate(part)
                     if x["day"] == row["day"]]                    # the tie group
            out.append({**row,
                "row_number": i + 1,
                "rank":       peers[0] + 1,
                "dense_rank": len({x["day"] for x in part[:i + 1]}),
                "run_rows":   sum(x["amount"] for x in part[:i + 1]),
                "run_range":  sum(x["amount"] for x in part[:peers[-1] + 1]),
            })
    return out          # len(out) == len(rows) — nothing collapsed

Cost

steptimespacenote
partition + sortO(n log n)O(n)free if an index already delivers (partition, order); otherwise rows are buffered and may spill to disk
ranking passO(n)O(1)one streaming pass over the sorted rows
running sum, ROWSO(n)O(1)a single accumulator
running sum, RANGEO(n)O(p)must peek ahead to the end of each peer group p before it can emit

The trade-off: a window returns all n rows where GROUP BY returns one per group, so the output (and the memory holding it) is bigger. You buy that with one sorted pass instead of a self-join or a correlated subquery, usually a very good deal.

Watch out for

Worked example

Take the seven orders in the demo: one row per order, and a dashboard that wants two things per region. The sequence of orders, and how revenue accumulates.

For the sequence, ROW_NUMBER() OVER (PARTITION BY region ORDER BY day, order_id) gives north 1, 2, 3, 4; the extra order_id makes the two tue orders deterministic instead of leaving it to the planner. If the question is instead "orders from the first two active days", DENSE_RANK() ... ORDER BY day gives 1, 2, 2, 3 and <= 2 returns all three mon-and-tue orders. RANK would give 1, 2, 2, 4: same rows here, but the 4 tells you a tie ate a number.

For revenue, the default RANGE frame makes o2 and o3 both read 450: the closing total for tuesday. Plot that and two points sit on top of each other; join it back to a per-order table and tuesday's revenue is counted twice. Under ROWS, o2 reads 300 and o3 reads 450, one step per order. Neither is wrong: RANGE answers "revenue through the end of my day", ROWS answers "revenue through my order". Decide which sentence you meant, then write the frame down so the next reader does not have to guess.

Check yourself

You need the three highest-value orders per region, and exactly three rows back even when values tie. Which do you reach for?

SUM(amount) OVER (PARTITION BY region ORDER BY day): two orders share a day. What does each of them report?