SQL · Chapter 8 · Concept lesson · 7 min read

Windows look at other rows without collapsing them

A window function computes over a set of related rows but writes its answer on every row, so nothing collapses. PARTITION BY picks the group, ORDER BY the sequence and the frame the rows in view.

The idea

GROUP BY answers a question about a group and gives you one row for the group. A window function answers the same kind of question but puts the answer on every row of the group. It looks at a set of related rows, computes something, and leaves the rows where they are.

The OVER (…) clause defines that set of rows, the window. Three parts do the work: PARTITION BY splits the rows into groups, ORDER BY puts each group in sequence, and the frame says which of those rows are in view for the current row.

Figure 1

GROUP BY folds rows together; OVER keeps every row

order_items
order_item_idorder_idline_total
1391049378.00
1401049107.98
14110491137.00
601123193.56
6021231294.30
GROUP BY order_id → SUM(line_total)
order_idorder_total
10491622.98
1231387.86
SUM(line_total) OVER (PARTITION BY order_id)
order_item_idline_totalorder_total
139378.001622.98
140107.981622.98
1411137.001622.98
60193.56387.86
602294.30387.86

Step 1 of 4 Five order lines that belong to two orders. We want each order's total.

  1. Five order lines that belong to two orders. We want each order's total.
  2. GROUP BY order_id folds each order's lines into one row. The totals are right, but the line details are gone: five rows became two.
  3. OVER (PARTITION BY order_id) also groups by order, but only to compute the sum. Order 1049's partition is its three lines, which add up to 1,622.98.
  4. The answer is written back onto every row of the partition. All five rows survive, so you can still show the line, its order total and, in one more step, the line's share.

Why it exists

Some questions need both the detail and the group. "Each product next to the best price in its category." "Each order line as a share of its order." "This month's orders next to last month's." With GROUP BY alone you aggregate in one query and join the result back to the detail in another. That works, but it is long, and it is easy to get the join key wrong.

Other questions are about order, which GROUP BY ignores: running totals, rankings, "the customer's second order", the gap between one order and the previous one. A window function does these in one pass over the rows, which is why analyst interviews lean on them.

How it works under the hood

For every row the database does the same four things.

  1. Partition. Find the rows in the same PARTITION BY group as this row. No PARTITION BY means the whole result is one partition.
  2. Order. Sort that partition by the window's ORDER BY.
  3. Frame. Choose which rows around the current one are in view.
  4. Compute. Run the function over the framed rows and write the answer on the current row.

The frame is where most of the power, and most of the confusion, lives.

Figure 2

The frame slides down the ordered rows

Orders per month, 2023
monthordersrunning_totalavg_3_months
2023-01192192192.0
2023-02157349174.5
2023-03191540180.0
2023-04216756188.0
2023-05218974208.3
2023-061891163207.7

Step 1 of 5 Months in order. For each row the window decides which rows are in view, the frame, and the function works only on those.

  1. Months in order. For each row the window decides which rows are in view, the frame, and the function works only on those.
  2. Running total: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. At March the frame is January to March, and 192 + 157 + 191 = 540.
  3. One row later, the same frame has gained April: 540 + 216 = 756. The start stays fixed and the end moves with the current row.
  4. Moving average: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW. At May the frame is only March, April and May, and (191 + 216 + 218) / 3 = 208.3.
  5. At June the frame slides forward: February drops out, June comes in. Both ends move, so the window keeps its size of three rows.

If the window has an ORDER BY and no explicit frame, the default frame runs from the start of the partition to the current row (and includes rows tied with it). That is why SUM(x) OVER (ORDER BY month) gives a running total, while SUM(x) OVER () gives the grand total on every row. For a moving average, write the frame yourself: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is a 7-row window. Note that it counts rows, not days, so a missing day silently stretches the window.

Percentages follow naturally. A line's share of its order is line_total / SUM(line_total) OVER (PARTITION BY order_id): the detail on top, the partition total underneath, no join back needed.

Three families of function

Aggregates over a window: SUM, AVG, COUNT, MIN, MAX. Same functions you know, now framed.

Ranking: ROW_NUMBER, RANK, DENSE_RANK, NTILE. They depend only on the order, and ties are what separates them.

Figure 3

Three rankings, one tie

Lighting, by unit_price descending
nameunit_pricerow_numberrankdense_rank
Pacifica Black Metal Table Lamp409.00111
Fern Rattan Desk Lamp379.00222
Marlow Glass Wall Sconce349.00333
Ashford Linen Pendant Light349.00433
Quarry Ceramic Picture Light329.00554

Step 1 of 4 Five Lighting products sorted by price. Two of them, the sconce and the pendant light, cost exactly $349.

  1. Five Lighting products sorted by price. Two of them, the sconce and the pendant light, cost exactly $349.
  2. ROW_NUMBER never ties. It numbers rows 1 to 5, and which tied product gets 3 and which gets 4 is arbitrary unless you add a tiebreaker to ORDER BY.
  3. RANK gives tied rows the same number and then skips: 3, 3, then 5. A "top 3" with RANK returns four products here.
  4. DENSE_RANK gives tied rows the same number and does not skip: 3, 3, then 4. Pick by what the question says about ties.

Navigation: LAG and LEAD read a value from the previous or next row of the partition. LAG(revenue) OVER (ORDER BY month) is last month's revenue next to this month's, and the first month gets NULL because there is nothing before it. FIRST_VALUE and LAST_VALUE read the edges of the frame.

Reading a window in one sentence

Say the OVER clause out loud and it becomes a sentence. SUM(line_total) OVER (PARTITION BY order_id) reads "add up line totals, within each order". LAG(orders) OVER (PARTITION BY channel ORDER BY month) reads "the previous month's orders, within each channel". If you cannot say who the "within each" is, the window is missing a PARTITION BY, and the numbers will blend groups that should stay apart.

Why the frame is a row count, not a time span

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means "this row and the two before it", whatever they contain. In the diagram that is three months only because every month has a row. If a month had no orders and no row at all, the same frame would reach back four months and quietly average the wrong period. The usual fix is to build a complete calendar first, with a row for every month or day, and join your data onto it before applying the window.

Where the window runs

Window functions run after WHERE, GROUP BY and HAVING, and before SELECT's final ORDER BY and LIMIT. Two consequences. You cannot filter on a window result in the same query's WHERE, so "top 3 per category" is a CTE or subquery that computes ROW_NUMBER and an outer query that keeps rn <= 3. And a window over an aggregated query sees the grouped rows: SUM(SUM(revenue)) OVER (ORDER BY month) is a running total of monthly totals.

Common misconceptions

"OVER (PARTITION BY x) is just GROUP BY x"

The grouping is the same, the output is not. GROUP BY returns one row per group. A window returns every input row. If your result has fewer rows than you expected, something collapsed them; if it has the same number, your window left them alone.

"LAG gets the previous row, so it works without PARTITION BY"

It gets the previous row in the window's order, across everything in the partition. With several customers in one result and no PARTITION BY customer_id, a customer's first order is compared with the last order of the previous customer. Whenever you compare to "before", ask before-for-whom.

"Ties don't matter if I use ROW_NUMBER"

They matter because ROW_NUMBER hides them: it breaks a tie arbitrarily, and the result can change between runs. Add a tiebreaker column to the ORDER BY for a stable answer, or use RANK or DENSE_RANK when tied rows should share a place.

Check yourself

One question. Answering it ticks this lesson off on the map; it never locks a level.

You run SELECT order_item_id, SUM(line_total) OVER (PARTITION BY order_id) AS order_total FROM order_items on the five lines above. How many rows come back, and what does line 601 show?

Pick what may run on this site. You can change this any time from "Cookie settings" at the bottom of every page.