skip to content

Why use a window function instead of joining back to a GROUP BY subquery for per-row aggregates?

level: middleimportance: should knowfreq 58%

answer

  1. count how many times you write the group key
  2. what happens when you add a WHERE clause
  3. the filter has to be repeated in one form
  4. one SELECT versus collapse-then-re-expand

basics

~20 s

Both put a group aggregate on every detail row, but the window version needs no subquery, no join key and no duplicated predicate: one SELECT over one row source, with the filter written once so the total always matches the rows shown.

solid answer

~40 s

The classic pre-window idiom aggregates in a derived table and joins the result back to the detail rows on the group key. It works, but it names the group key three times, needs the join predicate to be correct, and — the real trap — makes you repeat any `WHERE` clause inside the subquery *and* outside, or the totals stop describing the rows you display. `SUM(amount) OVER (PARTITION BY customer_id)` expresses the same thing in a single `SELECT`: the window is computed over the rows that survived this query's `WHERE`, so filter and total can never drift apart. The join-back form still earns its place when the aggregate comes from a **different table or grain** — counting `order_items` per order, say — or when you must target an engine without window functions.

code

sql · 15 lines
sql
-- Join-back: aggregate in a derived table, then re-attach to detail
SELECT o.order_id, o.customer_id, o.amount, t.customer_total
FROM orders o
JOIN (SELECT customer_id, SUM(amount) AS customer_total
      FROM orders
      WHERE status = 'PAID'      -- must be repeated here...
      GROUP BY customer_id) t
  ON t.customer_id = o.customer_id
WHERE o.status = 'PAID';         -- ...and here, or totals drift

-- Window: one row source, the predicate written once
SELECT order_id, customer_id, amount,
       SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders
WHERE status = 'PAID';

go deeper

for a junior

Know that both shapes exist and be able to read the join-back form. Recognising that OVER (PARTITION BY ...) replaces an aggregate subquery joined on the group key is enough at this stage.

for a middle

Write both versions on the spot and name the concrete failure of the join-back one: a predicate added in only one of the two places produces totals that do not match the displayed rows.

for a senior

Show where the boundary really sits — same-table group aggregate goes to a window, a different table or grain goes to an aggregate joined back or a lateral — and mention NULL group keys being dropped by the inner join.

for a principal

Own the portability call: whether the codebase may assume window functions at all given its target engines and versions, and whether legacy join-back queries get migrated or left alone once that floor is set.

## The problem both forms solve You want each detail row plus an aggregate of the group it belongs to: every order line with its customer's lifetime total, every employee with their department's average salary, every event with its session's event count. ## Form 1 — aggregate, then join back ```sql SELECT o.order_id, o.customer_id, o.amount, t.customer_total FROM orders o JOIN (SELECT customer_id, SUM(amount) AS customer_total FROM orders GROUP BY customer_id) t ON t.customer_id = o.customer_id; ``` The derived table collapses to one row per customer; the join re-expands it back onto the detail rows. This is correct, portable to any SQL engine ever shipped, and was the only option before window functions were widely available. ## Form 2 — window function ```sql SELECT order_id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders; ``` One row source, one `SELECT`, same result columns. ## What the window form buys you **The predicate lives in one place.** This is the argument that actually matters. Add `WHERE status = 'PAID'` to the join-back version and you must add it *inside the derived table too*. Put it only on the outer query and you get paid orders displayed next to totals that silently include unpaid ones — a wrong number that looks entirely plausible on a dashboard. In the window form there is one `WHERE`; the window is computed over exactly the rows that survived it, so the total always describes the rows on screen. **Fewer moving parts to get wrong.** No group key repeated in the `GROUP BY`, the `ON` clause and the select list; no risk of joining on the wrong column or an incomplete key. If the group key is a composite of three columns, the window form writes them once in `PARTITION BY`. **No accidental change in row count.** A join back on the true group key of an aggregated set is one-to-one per detail row, so it does not duplicate — but only if the join key really is the aggregate's grouping key. Get that wrong (join on a key the subquery did not group by) and detail rows multiply. The window form has no join, so this failure mode does not exist. **NULL group keys survive.** If `customer_id` can be `NULL`, the inner join drops those detail rows entirely, because `NULL = NULL` is not true. The window form keeps them and treats all `NULL` keys as one partition. Whether that is what you want is a design decision, but it should be a decision, not an accident. **It composes.** You can add a rank, a running total, a lag and a partition total in the same `SELECT` list, each with its own `OVER` clause. The join-back form needs one derived table per shape. ## When the join-back form is still right - **A different table or grain.** If the aggregate comes from another table — item counts from `order_items` attached to `orders` — you cannot partition `orders` to get it. Aggregate `order_items` in a derived table or CTE and join. (A correlated subquery or a lateral join are the other two shapes for that job.) - **A denominator that must ignore this query's filter.** Because the window sees only post-`WHERE` rows, a company-wide total inside a region-filtered query cannot come from a plain `SUM() OVER ()` in that same query. - **An engine without window functions.** MySQL added window functions in 8.0 and SQLite in 3.25; older targets need the join-back form. ## Readability, honestly assessed The window form is shorter but assumes the reader knows what `OVER` means. On a team where that is true — which today is most teams — it is the clearer statement of intent: "show me each row and its group's total" reads directly off the syntax, whereas the join-back version makes the reader reconstruct the intent from a collapse followed by a re-expansion. ## What an interviewer is checking That you can write both, that you name the duplicated-predicate trap rather than only saying "windows are cleaner", and that you know the boundary: same-table group aggregate → window; other table or other grain → aggregate and join.

  • When is the GROUP BY plus join still the right choice?
    When the aggregate comes from a different table or a different grain — item counts from a child table attached to the parent rows — because you cannot partition the parent table to produce it. Also when pre-aggregating a large side deliberately, or when targeting an engine without window functions.
  • In the join-back version, what breaks if you add WHERE status = 'PAID' only to the outer query?
    The displayed rows are paid orders, but customer_total still sums every order including unpaid ones. Nothing errors; the number is simply wrong and looks reasonable. The window version has one WHERE, so the total always covers exactly the rows returned.
  • Does the join-back version ever change the number of detail rows?
    Yes, if the join key is not the subquery's full grouping key. Joining detail to an aggregate grouped by a narrower key can match multiple aggregate rows per detail row and multiply them. The window form has no join and cannot do this.

saying these in an interview costs you the question

  • Says window functions cannot be combined with a WHERE clause
  • Claims the two forms are identical in every case
  • Forgets the filter must be repeated inside the aggregate subquery
  • Thinks a window function needs its own GROUP BY as well
  • Says joins are always faster so never use windows

context