Skip to main content

Aggregation and window functions

โฑ 30 min

Hookโ€‹

A change-data-capture feed hands you five rows for the same order: created, paid, paid again after a retry, shipped, address-corrected. You need exactly one row โ€” the current state of that order โ€” and you need it for a hundred million orders tonight. GROUP BY gives you a count or a max, but it throws away the columns you wanted to keep. The tool that keeps every row while still ranking them within their group is the window function, and it is the most useful SQL a data engineer learns after the join.

Conceptโ€‹

GROUP BY aggregation collapses many rows into one per group. Ten order lines become one revenue total; the individual lines are gone. That is exactly right when you want the total and nothing else.

A window function computes a value across a set of rows related to the current row โ€” its window โ€” without collapsing anything. Every input row survives in the output, now carrying an extra column: its rank, a running total, the value from the previous row. The difference in one line: aggregation returns one row per group; a window function returns one row per input row.

The shape of a window function is always the same:

function() OVER (PARTITION BY <group> ORDER BY <sort>)
  • PARTITION BY splits the rows into groups, like GROUP BY โ€” but the groups are not collapsed, just scoped.
  • ORDER BY orders the rows inside each partition, which is what makes running totals and rankings meaningful.

Three window functions cover most pipeline work:

FunctionGives youCommon use
ROW_NUMBER()A unique 1, 2, 3โ€ฆ within each partitionDeduplicate to one row per key
RANK()A ranking that ties and skips (1, 1, 3)Leaderboards, top-N per group
SUM() OVERA running or windowed totalCumulative revenue, moving averages

The pattern that earns its keep is deduplication. To keep one row per key, number the rows within each key by whatever "latest wins" means โ€” ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC) โ€” then keep only the rows numbered 1. This is the idiomatic way to collapse a change feed to current state, and it beats the alternatives (self-joins, correlated subqueries) on both readability and speed.

๐Ÿง  Knowledge check
1. What is the fundamental difference between a GROUP BY aggregation and a window function?
2. You want to keep only the most recent row for each order_id from a change feed. Which expression drives that?

Worked exampleโ€‹

Let me collapse a change feed to current state, then compute a running total โ€” the two windowing patterns you will reach for most.

Here is a small order_events feed. Order 9001 has three events; the latest is its true state:

import sqlite3

con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE order_events (
order_id INTEGER,
status TEXT,
updated_at TEXT
);
INSERT INTO order_events VALUES
(9001, 'created', '2026-07-01 10:00'),
(9001, 'paid', '2026-07-01 10:05'),
(9001, 'shipped', '2026-07-02 09:00'),
(9002, 'created', '2026-07-01 11:00'),
(9002, 'paid', '2026-07-01 11:30');
""")

To get one row per order, I number each order's events newest-first, then keep number 1. The numbering happens in a CTE so the outer query can filter on it โ€” you cannot filter on a window function in the same WHERE clause that computes it:

current = con.execute("""
WITH ranked AS (
SELECT order_id, status, updated_at,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY updated_at DESC
) AS rn
FROM order_events
)
SELECT order_id, status, updated_at
FROM ranked
WHERE rn = 1
ORDER BY order_id
""").fetchall()
for row in current:
print(row)
(9001, 'shipped', '2026-07-02 09:00')
(9002, 'paid', '2026-07-01 11:30')

Order 9001 correctly resolves to shipped and 9002 to paid. The retry and the earlier states are gone, but โ€” unlike a GROUP BY โ€” every column of the surviving row came along.

Now a running total, the other everyday window. Given a few days of sales, I want each day's cumulative total to date:

con.executescript("""
CREATE TABLE daily_sales (sales_day TEXT, amount NUMERIC);
INSERT INTO daily_sales VALUES
('2026-07-01', 100),
('2026-07-02', 250),
('2026-07-03', 175);
""")

running = con.execute("""
SELECT sales_day, amount,
SUM(amount) OVER (
ORDER BY sales_day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM daily_sales
ORDER BY sales_day
""").fetchall()
for row in running:
print(row)
('2026-07-01', 100, 100)
('2026-07-02', 250, 350)
('2026-07-03', 175, 525)

Each row keeps its own amount and gains the cumulative total through that day: 100, then 350, then 525. The ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW frame is what makes it cumulative โ€” it sums from the first row up to this one. Aggregation could give you the grand total 525; only a window can give you the total as of each day while keeping the daily rows.

Hands-onโ€‹

Your turn, on a different feed. The exercise below hands you an inventory_log with several rows per sku and asks you to produce the current quantity per SKU by keeping the latest log entry, then to add a running total of units received per warehouse.

โ–ถ SQL Kingdomde102-dedupe-inventory-log
Practice in SQL Kingdom โ†—

Success criteria: your deduplication returns exactly one row per sku โ€” the latest by timestamp โ€” and your running total matches the expected cumulative column. SQL Kingdom validates both result sets automatically.

Recapโ€‹

  • You can state the difference precisely: aggregation returns one row per group; a window function returns one row per input row with an added column.
  • You can deduplicate any feed to one row per key with ROW_NUMBER() OVER (PARTITION BY key ORDER BY โ€ฆ DESC) and a rn = 1 filter.
  • You can compute running totals and rankings with SUM() OVER and RANK() without collapsing the rows they describe.

Next up: what happens when these queries meet a large table. You will read the execution plan to see whether the engine is scanning every row or searching an index โ€” and learn to change the answer.

๐Ÿง  Knowledge check
1. Why must the ROW_NUMBER() deduplication filter (rn = 1) go in an outer query or CTE rather than the same WHERE clause?
2. Which frame makes SUM() OVER (ORDER BY day โ€ฆ) a cumulative running total?
๐Ÿ“Œ Key takeaways
  • Aggregation collapses groups to one row; window functions keep every row and add a scoped column.
  • ROW_NUMBER() OVER (PARTITION BY key ORDER BY โ€ฆ DESC) with rn = 1 is the idiomatic one-row-per-key deduplication.
  • Filter on a window result in an outer query or CTE, since WHERE runs before the window is computed.