01 · Window Functions¶
A window function computes a value across a set of rows related to the
current row — a "window" — without collapsing those rows into one, the way
GROUP BY does. Each input row keeps its own line in the output, but gets an
extra column computed from its neighbors: its rank within a group, a running
total, the value from the previous row, and so on. This is the tool for
"top N per category," running totals, and period-over-period comparisons —
things GROUP BY alone cannot express because it only produces one row per
group.
Sample schema¶
CREATE TABLE sales (
id INTEGER PRIMARY KEY,
salesperson TEXT NOT NULL,
region TEXT NOT NULL,
amount REAL NOT NULL,
sale_date TEXT NOT NULL
);
INSERT INTO sales (salesperson, region, amount, sale_date) VALUES
('Alice', 'West', 1200.00, '2026-01-05'),
('Alice', 'West', 900.00, '2026-01-12'),
('Alice', 'West', 1500.00, '2026-01-20'),
('Bob', 'West', 800.00, '2026-01-08'),
('Bob', 'West', 800.00, '2026-01-15'),
('Carol', 'East', 2000.00, '2026-01-03'),
('Carol', 'East', 1100.00, '2026-01-18'),
('Dave', 'East', 500.00, '2026-01-22');
The anatomy of OVER (...)¶
Every window function is a normal function followed by OVER (...), which
defines the window:
SELECT salesperson, region, amount,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num,
RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS dense_rnk
FROM sales
ORDER BY region, amount DESC;
salesperson region amount row_num rnk dense_rnk
----------- ------ ------ ------- --- ---------
Carol East 2000.0 1 1 1
Carol East 1100.0 2 2 2
Dave East 500.0 3 3 3
Alice West 1500.0 1 1 1
Alice West 1200.0 2 2 2
Alice West 900.0 3 3 3
Bob West 800.0 4 4 4
Bob West 800.0 5 4 4
PARTITION BY region splits the rows into independent windows (one per
region) — each restarts its numbering from 1. ORDER BY amount DESC decides
the order within each partition. The three ranking functions only differ
when there's a tie, visible on Bob's two 800.0 rows:
ROW_NUMBER()always assigns unique, sequential numbers (4, 5) — it doesn't care about ties at all.RANK()gives ties the same rank (4, 4) and then skips the next rank — there is no rank 5 in this partition.DENSE_RANK()gives ties the same rank (4, 4) too, but never skips — the next distinct value would get rank 5, not 6.
Picking the wrong one is a real bug: using ROW_NUMBER() for "top 3 per
region" arbitrarily breaks ties instead of including all of them, while
RANK() can make a "top 3" list return more or fewer than 3 rows if there's
a tie at the boundary.
Aggregate window functions — a running total¶
Aggregate functions you already know (SUM, AVG, COUNT, MIN, MAX)
also work as window functions — the difference from GROUP BY is that the
underlying rows aren't collapsed:
SELECT salesperson, sale_date, amount,
SUM(amount) OVER (PARTITION BY salesperson ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM sales
WHERE salesperson = 'Alice'
ORDER BY sale_date;
salesperson sale_date amount running_total
----------- ---------- ------ -------------
Alice 2026-01-05 1200.0 1200.0
Alice 2026-01-12 900.0 2100.0
Alice 2026-01-20 1500.0 3600.0
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is the frame clause —
it says "sum every row from the start of this partition up to and including
the current one," which is exactly a running total.
Frame clauses — a moving average¶
The frame doesn't have to start at the beginning. ROWS BETWEEN 1 PRECEDING
AND 1 FOLLOWING looks at the current row plus one neighbor on each side — a
3-row moving average:
SELECT salesperson, sale_date, amount,
ROUND(AVG(amount) OVER (ORDER BY sale_date
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING), 2) AS moving_avg
FROM sales
WHERE region = 'West'
ORDER BY sale_date;
salesperson sale_date amount moving_avg
----------- ---------- ------ ----------
Alice 2026-01-05 1200.0 1000.0
Bob 2026-01-08 800.0 966.67
Alice 2026-01-12 900.0 833.33
Bob 2026-01-15 800.0 1066.67
Alice 2026-01-20 1500.0 1150.0
Note there's no PARTITION BY here — with none, the whole result set is one
window, ordered by sale_date across every salesperson in the West region.
The first and last rows only average 2 values (they have no left/right
neighbor respectively) rather than erroring.
LAG and LEAD — looking at neighboring rows¶
LAG(col) reads a column's value from a previous row in the same window;
LEAD(col) reads it from a following row. Both are the standard tool for
period-over-period comparisons:
SELECT salesperson, sale_date, amount,
LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS prev_amount,
LEAD(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS next_amount,
amount - LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS change
FROM sales
WHERE salesperson = 'Alice'
ORDER BY sale_date;
salesperson sale_date amount prev_amount next_amount change
----------- ---------- ------ ----------- ----------- ------
Alice 2026-01-05 1200.0 900.0
Alice 2026-01-12 900.0 1200.0 1500.0 -300.0
Alice 2026-01-20 1500.0 900.0 600.0
The first row has no prev_amount (nothing precedes it) and the last has no
next_amount — both come back NULL rather than erroring, so any
arithmetic built on them (like change) is also NULL at the edges. Both
functions accept an optional second argument, LAG(amount, 1, 0), to supply
a default instead of NULL.
Percent of a group's total¶
Combining a window aggregate with the row's own value is how you compute "this row's share of its group" without a self-join:
SELECT region, salesperson, amount,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY region), 1) AS pct_of_region
FROM sales
ORDER BY region, amount DESC;
region salesperson amount pct_of_region
------ ----------- ------ -------------
East Carol 2000.0 55.6
East Carol 1100.0 30.6
East Dave 500.0 13.9
West Alice 1500.0 28.8
West Alice 1200.0 23.1
West Alice 900.0 17.3
West Bob 800.0 15.4
West Bob 800.0 15.4
SUM(amount) OVER (PARTITION BY region) — with no ORDER BY inside the
OVER(...) — computes the total for the entire partition on every row,
which is exactly what's needed as the denominator.
The default frame trap¶
Leaving out an explicit frame clause is dangerous the moment you add an
ORDER BY to the window, because SQL then silently defaults to RANGE
BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW instead of covering every row
in the partition:
SELECT salesperson, region, amount,
SUM(amount) OVER (PARTITION BY region ORDER BY amount) AS default_frame_sum
FROM sales
WHERE region = 'West'
ORDER BY amount;
salesperson region amount default_frame_sum
----------- ------ ------ -----------------
Bob West 800.0 1600.0
Bob West 800.0 1600.0
Alice West 900.0 2500.0
Alice West 1200.0 3700.0
Alice West 1500.0 5200.0
This looks like a running total, and mostly is — except it's a trap: as
soon as you add ORDER BY inside OVER(...) without writing an explicit
ROWS/RANGE frame, the default frame changes from "the whole partition" to
"start of partition through current row," turning what looks like "West's
total" into a running sum instead. Both Bob rows show 1600.0 (tied rows
under the default RANGE frame are treated as one peer group), which can
easily be mistaken for "the region total" if you aren't watching for it. The
fix is the same rule from the running-total example above: if you want the
whole partition's total on every row, either drop ORDER BY from the
OVER(...) entirely, or write the frame explicitly.
Cheat sheet¶
| Function | Behavior on ties | Typical use |
|---|---|---|
ROW_NUMBER() |
Unique, sequential — ties broken arbitrarily | Paginate, dedupe, pick exactly N rows |
RANK() |
Ties share a rank; next rank is skipped | Leaderboards where gaps after ties are expected |
DENSE_RANK() |
Ties share a rank; no gap afterward | Leaderboards where ranks must stay consecutive |
LAG(col, n, default) |
— | Previous row's value; period-over-period change |
LEAD(col, n, default) |
— | Next row's value |
SUM()/AVG()/COUNT() OVER(...) |
— | Running totals, moving averages, percent-of-group |
No ORDER BY in OVER(...) |
— | Frame defaults to the whole partition |
ORDER BY present, no explicit frame |
— | Trap: frame defaults to UNBOUNDED PRECEDING through current row |
How It Actually Works¶
Window functions require a fundamentally different execution strategy than
aggregates: instead of collapsing rows into one output row per group, SQLite
must keep every row in the partition intact while giving each one access to
values computed across its window frame. Internally, the engine first
sorts (or seeks via an index already in the right order) the rows by the
PARTITION BY and ORDER BY columns together, then makes a single pass
maintaining a sliding frame — for ROWS BETWEEN frames it's a literal
buffer of the rows currently in scope; for ranking functions (ROW_NUMBER,
RANK, DENSE_RANK) it just tracks a running counter and the previous row's
sort-key values to detect ties. This is materially more expensive than a
plain aggregate: the sort step is unavoidable unless a supporting index
already provides that exact order, and the per-partition frame state has to
be recomputed from scratch every time the partition key changes. LAG/LEAD
are implemented as fixed-offset frame lookups — the engine keeps a small
rolling buffer of prior/upcoming rows rather than re-scanning the table for
each call.
Exercise¶
Using the sales schema above:
- Find each salesperson's rank by
amountwithin their own region usingRANK(), and explain in your own words whyBob's tie produces the result it does. - Write a running total of
amountper region, ordered bysale_date, using an explicitROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWframe. - Using
LAG(), find the change inamountbetween each salesperson's consecutive sales, and identify which sale had the biggest drop. - Compute each region's total revenue as a window function (no explicit
ORDER BYinsideOVER(...)) and use it to find what percentage each individual sale contributed to its region's total.