◐ ai-teacher

🗄 SQL & Databases · Chapter 6

SQL: window functions vs GROUP BY

GROUP BY collapses rows. A window function keeps every row and adds the group's answer beside it.

Study this lesson free in the app →

The basics

A window function computes something across a group of related rows — an average, a rank, the previous value — and writes the answer onto every row instead of collapsing the group into one.

The problem it solves: GROUP BY answers "average fare per city" but throws away the individual rides. Real questions need both: "show each ride and how far it is above its city's average", "keep only the latest event per order", "time since this driver's previous trip". Before window functions you needed self-joins that were slow and easy to get wrong.

The mental model: each row looks through a window at its neighbours. PARTITION BY decides who counts as a neighbour (same city, same driver). ORDER BY inside OVER() lines those neighbours up in sequence. The frame decides how far along that line the row can see — all of it, or just the last 7 rows.

Words you must know 5

OVER()
the clause that turns a normal function into a window function.
PARTITION BY
the grouping, without collapsing.
ROW_NUMBER / RANK / DENSE_RANK
three ways to number rows; they differ only on ties.
LAG / LEAD
read the previous / next row's value.
Frame
ROWS BETWEEN …, the slice of the partition the function actually sees.

Explained

"Show each ride, plus how that fare compares to the city average."

With GROUP BY you lose the individual rides. With a window function you keep them and get the average.

Half of all SQL interview questions are secretly this distinction.

The class test results

A teacher has every student's marks.

GROUP BY is the summary sheet pinned to the noticeboard: Class A average 72, Class B average 68. Two rows. The individual students are gone.

A window function is writing on each student's own paper: "You: 81. Class average: 72. Rank in class: 3." Every student still has their sheet — each one now carries the context of its group.

Same calculation. The difference is whether the detail survives.

The syntax, minimally

-- GROUP BY: 1 row per city
SELECT city, AVG(fare) AS avg_fare
FROM rides
GROUP BY city;

-- WINDOW: every ride kept, city average attached
SELECT ride_id, city, fare,
       AVG(fare) OVER (PARTITION BY city) AS city_avg,
       fare - AVG(fare) OVER (PARTITION BY city) AS diff
FROM rides;

PARTITION BY = the grouping. ORDER BY inside OVER() = the sequence within the group. No PARTITION BY = the whole result set is one window.

The four you must know cold

-- 1. ROW_NUMBER — deduplicate: keep the latest row per ride
WITH ranked AS (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY ride_id
                            ORDER BY event_time DESC) AS rn
  FROM ride_events
)
SELECT * FROM ranked WHERE rn = 1;

-- 2. LAG — time between consecutive rides for a driver
SELECT driver_id, ride_time,
       ride_time - LAG(ride_time) OVER (PARTITION BY driver_id
                                        ORDER BY ride_time) AS idle_gap
FROM rides;

-- 3. Running total — cumulative revenue by day
SELECT ride_date, daily_revenue,
       SUM(daily_revenue) OVER (ORDER BY ride_date
                                ROWS BETWEEN UNBOUNDED PRECEDING
                                         AND CURRENT ROW) AS running_total
FROM daily_revenue;

-- 4. RANK vs DENSE_RANK vs ROW_NUMBER on ties (100, 100, 90)
--    ROW_NUMBER  → 1, 2, 3   (arbitrary tie-break, always unique)
--    RANK        → 1, 1, 3   (gap after the tie)
--    DENSE_RANK  → 1, 1, 2   (no gap)

That ROW_NUMBER dedup pattern is the single most-asked data-engineering SQL question there is. Write it until it is muscle memory.

Where windows run in the query

Logical order of evaluation:

FROM → WHERE → GROUP BY → HAVING → WINDOW → SELECT → ORDER BY → LIMIT

Two consequences that trip people up:

  1. You cannot filter on a window function in WHERE. The window has not been computed yet. Wrap it in a CTE or subquery and filter outside — that is why the dedup pattern needs WITH ranked AS (...)
  2. Windows run after GROUP BY, so a window over an aggregate is legal: SUM(SUM(fare)) OVER (ORDER BY day) computes a running total of daily sums. It looks wrong; it is correct.

The cost at volume

A window function generally forces a sort within each partition. On a billion-row table that is where your warehouse bill and your runtime go.

What actually helps:

GROUP BY collapses. OVER() accompanies.

PARTITION BY = which group. ORDER BY inside OVER = the sequence within it.

Cannot filter a window in WHERE — it runs after. Use a CTE, or QUALIFY.

Gotchas

Words to know

Window functionA calculation across a set of rows related to the current row, without collapsing rows.
PARTITION BYDefines the group a window function operates within.
Frame clauseROWS/RANGE BETWEEN … — which rows inside the partition the function sees.
QUALIFYSnowflake/Databricks/BigQuery clause that filters on a window function result directly.
ROW_NUMBERUnique sequential number per partition; the standard deduplication tool.
DENSE_RANKRanking that assigns equal values the same rank with no gaps after ties.
SessionisationGrouping a user's events into sessions based on inactivity gaps.
CTECommon Table Expression — a named subquery introduced with WITH.

In two minutes

Test yourself

1. You need every ride row AND its city's average fare on the same row. Use…

  1. GROUP BY city
  2. AVG(fare) OVER (PARTITION BY city)
  3. HAVING
  4. DISTINCT
Show the answer

AVG(fare) OVER (PARTITION BY city) — GROUP BY collapses to one row per city. The window function keeps every ride and attaches the group's value.

2. Why does the dedup pattern need a CTE?

  1. CTEs run faster
  2. WHERE is evaluated before window functions, so rn is not available yet
  3. ROW_NUMBER only works in CTEs
  4. To avoid a full scan
Show the answer

WHERE is evaluated before window functions, so rn is not available yet — Logical order: WHERE → GROUP BY → WINDOW → SELECT. Filter on the window result in an outer query, or use QUALIFY where supported.

3. Marks are 100, 100, 90. What does RANK() return?

  1. 1, 2, 3
  2. 1, 1, 2
  3. 1, 1, 3
  4. 1, 1, 1
Show the answer

1, 1, 3 — RANK leaves a gap after ties. DENSE_RANK gives 1,1,2 and ROW_NUMBER gives 1,2,3 with an arbitrary tie-break.

4. Your ROW_NUMBER dedup returns different rows on each run. Why?

  1. The table changed
  2. The ORDER BY inside OVER() is not deterministic — ties break arbitrarily
  3. ROW_NUMBER is random by design
  4. Missing PARTITION BY
Show the answer

The ORDER BY inside OVER() is not deterministic — ties break arbitrarily — With tied ORDER BY values the engine may pick either row. Add a unique tiebreaker such as event_id to make it reproducible.

5. Which is the cheapest way to make a big window query faster?

  1. Add more columns to SELECT
  2. Filter/prune partitions before the window, inside the CTE
  3. Use RANK instead of ROW_NUMBER
  4. Remove the PARTITION BY
Show the answer

Filter/prune partitions before the window, inside the CTE — Windows sort within each partition. Cutting the input rows first — using the table's physical partitioning — reduces the sort and the scan together.

6. orders_events has several rows per order (created, paid, shipped, delivered). Which query returns each order's latest status?

  1. SELECT order_id, MAX(status) FROM orders_events GROUP BY order_id
  2. ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY event_time ASC) and keep rn = 1
  3. ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY event_time DESC, event_id DESC) and keep rn = 1
  4. SELECT DISTINCT order_id, status FROM orders_events
Show the answer

ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY event_time DESC, event_id DESC) and keep rn = 1 — Latest means ordering by time descending, with a tiebreaker for identical timestamps. MAX(status) picks the alphabetically largest string ('shipped' beats 'delivered'), ASC picks the earliest event, DISTINCT returns every status.

There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →