Exercise 10 of 10 · Window functions
Two per Shelf
What you will make
A per-shelf leaderboard of a tool library's two dearest tools on each shelf — the top-N-per-group query that people learn window functions in order to write.
The one new idea: PARTITION BY restarts a window function's numbering for each group
"The three most recent orders per customer", "the top two errors per service", "the best-selling item per region" are all the same query, and it is the one question that is genuinely awkward without window functions. Once you can see that PARTITION BY is what makes the numbering restart, the whole family of problems collapses into one pattern.
Go straight to the code ↓Numbering rows
row_number() is a window function that gives each row its position in the window. It needs an ORDER BY
inside the OVER brackets, because "position" is meaningless without one.
CREATE TABLE hives (id INTEGER PRIMARY KEY, label TEXT, apiary TEXT, jars INTEGER);
INSERT INTO hives (label, apiary, jars) VALUES
('north hedge', 'home', 14),
('orchard', 'home', 31),
('lane end', 'roadside', 6),
('churchyard', 'roadside', 22),
('back field', 'home', 9);
SELECT
label,
apiary,
jars,
row_number() OVER (ORDER BY jars DESC, label) AS place
FROM hives
ORDER BY place;label | apiary | jars | place
------------+----------+------+------
orchard | home | 31 | 1
churchyard | roadside | 22 | 2
north hedge | home | 14 | 3
back field | home | 9 | 4
lane end | roadside | 6 | 5
(5 rows)One to five, straight down the table. The , label after jars DESC is a tie-breaker: without it, two hives
with the same count would be numbered in an order the database chooses and may choose differently next time.
PARTITION BY restarts the count
PARTITION BY splits the rows into groups and gives each row a window containing only its own group. It is
the window equivalent of GROUP BY — with the crucial difference that nothing is collapsed.
CREATE TABLE hives (id INTEGER PRIMARY KEY, label TEXT, apiary TEXT, jars INTEGER);
INSERT INTO hives (label, apiary, jars) VALUES
('north hedge', 'home', 14),
('orchard', 'home', 31),
('lane end', 'roadside', 6),
('churchyard', 'roadside', 22),
('back field', 'home', 9);
SELECT
apiary,
label,
jars,
row_number() OVER (PARTITION BY apiary ORDER BY jars DESC, label) AS place_in_apiary,
count(*) OVER (PARTITION BY apiary) AS hives_in_apiary
FROM hives
ORDER BY apiary, place_in_apiary;apiary | label | jars | place_in_apiary | hives_in_apiary
---------+-------------+------+-----------------+----------------
home | orchard | 31 | 1 | 3
home | north hedge | 14 | 2 | 3
home | back field | 9 | 3 | 3
roadside | churchyard | 22 | 1 | 2
roadside | lane end | 6 | 2 | 2
(5 rows)The numbering now runs 1, 2, 3 within home and 1, 2 within roadside. Inside the brackets the order of the
parts is fixed: PARTITION BY first, then ORDER BY, then a frame clause if there is one.
Why the filter needs a CTE
Now the obvious next step: keep the rows where place_in_apiary <= 2. You cannot do it in the same query.
CREATE TABLE hives (id INTEGER PRIMARY KEY, label TEXT, apiary TEXT, jars INTEGER);
INSERT INTO hives (label, apiary, jars) VALUES ('north hedge', 'home', 14), ('orchard', 'home', 31);
SELECT label, apiary
FROM hives
WHERE row_number() OVER (PARTITION BY apiary ORDER BY jars DESC) <= 2;Error in the statement on line 4: misuse of window function row_number()WHERE is evaluated before the window functions are, so there is no row number yet to compare. The fix is to
compute the numbering in one step and filter in the next, which a CTE makes readable:
CREATE TABLE hives (id INTEGER PRIMARY KEY, label TEXT, apiary TEXT, jars INTEGER);
INSERT INTO hives (label, apiary, jars) VALUES
('north hedge', 'home', 14),
('orchard', 'home', 31),
('lane end', 'roadside', 6),
('churchyard', 'roadside', 22),
('back field', 'home', 9);
WITH ranked AS (
SELECT
apiary,
label,
jars,
row_number() OVER (PARTITION BY apiary ORDER BY jars DESC, label) AS place
FROM hives
)
SELECT apiary, place, label, jars
FROM ranked
WHERE place <= 2
ORDER BY apiary, place;apiary | place | label | jars
---------+-------+-------------+-----
home | 1 | orchard | 31
home | 2 | north hedge | 14
roadside | 1 | churchyard | 22
roadside | 2 | lane end | 6
(4 rows)Four rows: the best two from each apiary. That is the whole top-N-per-group pattern, and it is worth being able to write from memory.
row_number or rank?
They differ only on ties, and the difference decides how many rows you get back.
row_number()never repeats a number, so<= 2always gives at most two rows per group — but when two rows tie, which of them is second is decided by your tie-breaker, or arbitrarily if you did not supply one.rank()gives tied rows the same number and then skips, so<= 2can return three or more rows per group. That is right for a prize list and wrong for "show me two rows".
Pick deliberately. For a leaderboard people will read, rank() is usually honest; for a fixed-size list,
row_number() with a real tie-breaker.
Your turn
The editor holds eight tools across four shelves and a query that already has the right shape: a CTE that numbers the rows, and an outer query that keeps the low numbers.
Press Run first. Two rows come back, both from the whole-library top two, because the numbering runs straight down the table and ignores the shelves.
Add one clause inside the OVER (...) brackets so the numbering restarts for each shelf. Run it again and
six rows should come back: two for garden, one for hand, one for plumbing and two for power.
Then change WHERE place <= 2 to <= 1 to get the dearest tool on every shelf — a query you will want
surprisingly often.
If something goes wrong
If you still get two rows, the new clause is probably outside the OVER brackets, where it does nothing to
the window.
If you get a syntax error near PARTITION, it is after the window's ORDER BY. Inside the brackets,
PARTITION BY comes first.
If you get eight rows, the WHERE place <= 2 has gone missing from the outer query.
If you get misuse of window function row_number(), the window function has been moved into the WHERE
clause. It has to stay inside the CTE.
Nothing here can break. The table is rebuilt from scratch on every run.
Write your code
Runs in your browser. Press Run (or Ctrl/Cmd+Enter) and the output is checked for you.
Press Esc then Tab to move keyboard focus out of the code editor.
Output will appear here after you run your code.The runtime is starting in the background. You can type now — it will be ready before you are.
The answer appears here once you have run your code at least once.
Things that often go wrong here
- Putting PARTITION BY after the window's ORDER BY
- `OVER (ORDER BY deposit DESC PARTITION BY shelf)` is a syntax error. Inside the brackets the parts have a fixed order: PARTITION BY, then ORDER BY, then any frame clause.
- Adding GROUP BY shelf to the CTE instead
- That collapses each shelf to one row, so there is nothing left to number and the individual tool names are gone. PARTITION BY does the grouping a window function needs while keeping every row, which is exactly the difference between the two.
- Filtering on the window function directly
- `WHERE row_number() OVER (...) <= 2` fails with `misuse of window function row_number()`. WHERE is evaluated before window functions are computed, which is why the numbering has to happen in a CTE and the filtering in the query outside it. Some data warehouses offer QUALIFY for this; standard SQL, SQLite, PostgreSQL and MySQL all use the CTE.
- Using rank() and expecting exactly two rows per shelf
- `rank()` gives tied rows the same number, so a shelf with two tools at the same deposit would return three rows for `rank <= 2` — which may be exactly what a prize list wants and is not what "give me two rows" means. `row_number()` never ties, and the `, name` in its ORDER BY is what decides the order when two deposits are equal.
- Expecting the one-tool shelves to be padded to two rows
- There is nothing to pad them with. A top-N-per-group query returns up to N rows per group, and the hand and plumbing shelves have one tool each, so they contribute one row each. Six rows from four shelves is the correct answer.
Longer explanation: read the full lesson. Want a blank editor instead? Open the SQL playground.