All 22 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
The first module of Part 4, and the one that separates people who can query
a database from people who can analyse one. GROUP BY answers
"what is the total" by throwing the detail away. A window function answers
it and keeps every row — so each row can be compared against its own group,
ranked within it, and shown as a share of it.
Ready?
1
GROUP BY Collapses. OVER Does Not.
Everything in Part 2 came at a price. GROUP BY gives you
the total, and in exchange it destroys the rows it summarised.
Twenty-four orders go in, ten country rows come out, and no individual
order survives to be looked at.
Most analyst questions want both at once. "Show me each order and how it
compares to the average." "Rank these customers." "What share of revenue
is this?" Every one of those needs the detail and a summary of
it on the same row.
24 rows in, 10 out
GROUP BY
SELECT country, SUM(amount) FROM orders JOIN users … GROUP BY country;
One row per country. Which orders made up that total is no longer answerable.
24 rows in, 24 out
OVER ()
SELECT order_id, amount, SUM(amount) OVER () FROM orders;
Every order, each carrying the grand total beside it.
The brackets after OVER are the window:
which rows this calculation is allowed to look at. Empty brackets mean
all of them. That is the entire concept — a second pass over the same
rows, reported alongside the first.
A window around an aggregate
The share-of-total idiom looks like a typo the first time you meet it,
and it is worth reading slowly:
SELECT u.country,
SUM(o.amount) AS revenue,
SUM(SUM(o.amount)) OVER () AS all_revenue
FROM orders o JOIN users u ON u.user_id = o.user_id
GROUP BY u.country;
The innerSUM is the ordinary aggregate,
computed per country. The outer one is a window over
the rows GROUP BY has already produced — nine country rows
— and totals those. Grouping happens first; the window sees its output.
Divide one by the other and you have a percentage in a single pass.
Quick check
SELECT order_id, SUM(amount) OVER () FROM orders against a 24-row table returns how many rows?
2
Narrowing the Window
OVER () looked at every row. PARTITION BY
narrows the window to rows sharing a value — so each
row is measured against its own group rather than the whole table.
AVG(o.amount) OVER (PARTITION BY u.country)
Read it as "the average, restarted for each country". Every order still
appears; each now carries the average of the country it belongs to.
This is the correlated subquery from 3-05
Module 3-05 answered "orders above their own country's average" with a
correlated subquery that re-ran once per row. This is the same
question, computed in one pass instead of twenty-four, and readable on
one line. The correlated form is still worth knowing — some engines
lack windows — but on Postgres this is the answer.
PARTITION BY is not GROUP BY, and the
difference is worth being precise about: GROUP BY decides
how many rows come back, PARTITION BY
decides what each row's calculation can see. They can
appear in the same query and mean different things, which is how the
share-of-total idiom works.
Two ORDER BYs
Once a window has an ORDER BY inside it, there are two in
the query and they do unrelated jobs:
IN
Inside OVER
Decides what gets number 1 — the order the window function counts in.
OUT
At the end of the query
Decides the order rows are printed in. Frequently the same; never required to be.
Quick check
You replace OVER (PARTITION BY u.country) with OVER () in an "orders above their country average" query. What happens?
3
Three Ranking Functions That Only Differ on Ties
With no ties, RANK, DENSE_RANK and
ROW_NUMBER return identical columns. That is precisely why
the difference between them tends to be learned from a bug report.
In this database five users have 3 orders each and four have 2, so the
disagreement is visible:
users ranked by order count, descending
name
orders
RANK
DENSE_RANK
ROW_NUMBER
Ada Lovelace
3
1
1
1
Grace Hopper
3
1
1
2
Mei Lin
3
1
1
3
Nia Mwangi
3
1
1
4
Priya Nair
3
1
1
5
Kenji Tanaka
2
6
2
6
Rosa Diaz
2
6
2
7
The shaded row is where they part company. RANK skips to
6 because five people are ahead. DENSE_RANK says 2 because
it ranks values, not rows. ROW_NUMBER never ties
at all.
RANK
Ties share, then it skips
Competition ranking — three joint silvers and no bronze. Use when the position should reflect how many are genuinely ahead.
DENSE
Ties share, no gap
Ranks distinct values. Use for "which price tier is this", where the count of rows above is irrelevant.
ROW
Always 1, 2, 3
Use when you need exactly one row per position — deduplicating, or picking a single winner per group.
ROW_NUMBER without a tiebreaker is not reproducible
Among equal rows, ROW_NUMBER picks an order the database
finds convenient, and it may differ between runs or after an index
change. If the numbering matters, add a second sort key that is
unique — ORDER BY amount DESC, order_id. A "top 10" that
quietly reshuffles is a bug nobody can reproduce.
Choosing wrongly is not a style error. "Who is in the top 3?" answered
with ROW_NUMBER silently cuts off two of five equal leaders,
and the report will not mention that it did.
NTILE(n) belongs to the same family: it splits the window
into n equal-sized buckets for quartiles and deciles. It divides
by position, not value, so equal values can land in
different buckets — its job is to keep the buckets the same size.
Quick check
Four salespeople tie for first place. What does the fifth get from RANK and from DENSE_RANK?
4
The Rule That Catches Everyone
The obvious next move is to filter on the number you just computed. It
does not work, and Postgres is blunt about why:
WHERE ROW_NUMBER() OVER (ORDER BY amount DESC) <= 3
ERROR: window functions are not allowed in WHERE
This is a consequence of ordering rather than an arbitrary restriction.
Window functions are evaluated afterWHERE, GROUP BY and HAVING — they
run on whatever survived them. At the moment WHERE is
deciding, no window has been computed yet. The same error appears for
GROUP BY and HAVING.
One useful consequence: a WHERE in the same query filters
the rows the window then sees. Restrict to paid orders and every running
total and average is over paid orders only — usually exactly what you
wanted.
Two passes, and the pattern it unlocks
To filter on a window value, compute it in a CTE and filter
outside. That two-step is the single most useful shape in Part 4:
the top N rows within each group.
WITH ranked AS (
SELECT u.country, o.order_id, o.amount,
ROW_NUMBER() OVER (PARTITION BY u.country
ORDER BY o.amount DESC, o.order_id) AS rn
FROM orders o JOIN users u ON u.user_id = o.user_id
WHERE o.status = 'paid'
)
SELECT country, order_id, amount
FROM ranked
WHERE rn = 1;
"The biggest order in each country" resists everything before this
module. MAX(amount) gives the number but not the order it
belongs to; grouping and joining back is fiddly and duplicates on ties.
Number within the partition, then keep number one.
= 1
One winner per group
Change to <= 3 for a top three. The pattern does not otherwise change.
ROW
ROW_NUMBER for exactly one
A tie is broken by your tiebreaker, and you get one row per group whatever happens.
RANK
RANK to keep genuine ties
Two orders tied at the top both survive. That is the whole decision — and it changes the row count.
Next: frames
Every window here has looked at a whole partition at once. Module 4-02
adds the frame — the ability to look at rows up to this one,
or the row before, or a trailing seven days — which is what running
totals, month-on-month change and moving averages are made of.
Quick check
You need the two most recent orders per customer. Which shape?
0 of 9 completed
Loading the tables…
01
To do
GROUP BY answers "what is the total" by
destroying the detail. Twenty-four orders go in, ten
country rows come out, and no individual order survives to be looked at.
A window function computes the same kind of summary
without collapsing anything. Add OVER () to an aggregate and
every row stays, each one now carrying the total alongside its own
values.
SELECT order_id, amount, SUM(amount) OVER () AS all_orders FROM orders; -- 24 rows, every one showing the same grand total
The empty brackets are the window: which rows this
calculation may look at. Empty means "all of them". That is the whole
idea — a second pass over the same rows, reported next to the first.
Your task: return order_id,
amount, and the total of all order amounts as
all_orders, for every order, ordered by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
SUM(o.amount) OVER () AS all_orders. The brackets after OVER stay empty — you want every row in the window.
Output
02
To do
"What percentage of revenue is each country?" needs two numbers at
different levels at once: the country's revenue, and everyone's. Before
windows that meant computing the total separately and joining it back.
A window can wrap an aggregate. It looks wrong the first time:
SELECT u.country, SUM(o.amount) AS revenue, SUM(SUM(o.amount)) OVER () AS all_revenue FROM orders o JOIN users u ON u.user_id = o.user_id GROUP BY u.country;
The inner SUM is the ordinary aggregate, computed per
country. The outer one is a window over the grouped
rows — the GROUP BY has already happened, so
the window sees nine country rows and totals those. It is not a typo,
and it is the standard way to write a share.
Your task: for paid orders, return each
country, its revenue, and its
pct of total revenue rounded to one decimal place. Order by
revenue descending, then country.
query.sql
PostgreSQLCtrl↵ to run
Hint
The denominator is SUM(SUM(o.amount)) OVER () — a window over the already-grouped rows. The 100.0 keeps it out of integer division, which was module 2-05.
Output
03
To do
OVER () looked at everything. PARTITION BY
narrows the window to rows sharing a value — so each
row is compared against its own group rather than the whole table.
AVG(o.amount) OVER (PARTITION BY u.country)
Read it as "the average, restarted for each country". Every order still
appears; each now carries the average for the country it belongs to.
This is the same question the correlated subquery answered in module
3-05 — compare a row against an aggregate of its own group — written in
one line and computed in one pass rather than re-running an inner query
per row.
Your task: for paid orders, return
order_id, country, amount, and
that country's average order value rounded to two decimals as
country_avg. Order by country, then order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
AVG(o.amount) OVER (PARTITION BY u.country) goes inside the ROUND. There is no GROUP BY — the rows are not being collapsed.
Output
04
To do
ROW_NUMBER() hands each row a position: 1, 2, 3. It takes
no argument, and it needs an ORDER BYinside the
OVER to know what order it is numbering.
ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC)
Two different ORDER BYs are now in play and they do
different jobs. The one inside OVER decides
what gets number 1. The one at the end of the query
decides what order the rows are printed in. They are
frequently the same and do not have to be.
Your task: for paid orders, return
country, order_id, amount, and a
rn numbering that country's orders from most to least
valuable. Break amount ties with the lower order_id first. Print ordered
by country, then rn.
query.sql
PostgreSQLCtrl↵ to run
Hint
ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC, o.order_id) AS rn — the second sort key is the tiebreaker, without which the numbering is not reproducible.
Output
05
To do
With no ties the three ranking functions are identical, which is why the
difference between them is usually learned the hard way. Here there are
real ties: five users have 3 orders each, and four have 2.
RANK
Ties share, then it skips
1, 1, 1, 1, 1, 6 — five joint firsts, and the next is sixth. Competition ranking.
DENSE
Ties share, no gap
1, 1, 1, 1, 1, 2 — the next distinct value is second. Ranks the values, not the rows.
ROW
No ties, ever
1, 2, 3, 4, 5, 6 — arbitrary among equals unless you add a tiebreaker.
Which one is right depends on the question. "Who is in the top 3?" with
ROW_NUMBER silently cuts off two of five equal leaders and
the report will not mention it.
Your task: return every user's name, their
orders count, and all three rankings by order count
descending, as rnk, dense and rn.
Break the ROW_NUMBER tie by name. Order by orders descending, then name.
query.sql
PostgreSQLCtrl↵ to run
Hint
All three take the aggregate directly: RANK() OVER (ORDER BY COUNT(o.order_id) DESC), the same for DENSE_RANK(), and ROW_NUMBER() OVER (ORDER BY COUNT(o.order_id) DESC, u.name).
Output
06
To do
The rule that catches everyone, and Postgres is blunt about it:
WHERE ROW_NUMBER() OVER (ORDER BY o.order_id) <= 3 ERROR: window functions are not allowed in WHERE
Not a limitation so much as a consequence of ordering. Window functions
are evaluated afterWHERE,
GROUP BY and HAVING — they run on whatever
survived those. So at the moment WHERE is deciding, no
window has been computed yet. The same error appears for
GROUP BY and HAVING.
It does mean a WHERE in the same query filters the rows the
window then sees. Filter to paid orders and the running totals and
averages are over paid orders only, which is usually what you wanted.
To filter on a window value, compute it in a CTE and filter
outside — a second pass, and the standard shape.
Your task: return the order_id and
amount of the three largest paid orders,
with their rn, using a CTE and ROW_NUMBER.
Break ties by the lower order_id. Order by rn.
query.sql
PostgreSQLCtrl↵ to run
Hint
Inside the CTE: SELECT o.order_id, o.amount, ROW_NUMBER() OVER (ORDER BY o.amount DESC, o.order_id) AS rn FROM orders o WHERE o.status = 'paid'. Then filter WHERE rn <= 3 outside it.
Output
07
To do
Now the pattern this all builds to, and probably the single most useful
thing in Part 4: the top N rows within each group.
"The biggest order in each country" resists everything so far.
MAX(amount) gives the number but not the order it belongs
to. Grouping and then joining back is possible and fiddly, and duplicates
when two orders tie.
Number within the partition, then keep number one:
WITH ranked AS ( SELECT …, ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC, o.order_id) AS rn FROM … ) SELECT … FROM ranked WHERE rn = 1;
Change = 1 to <= 3 for a top three. Use
RANK() instead if genuine ties should all be kept — that is
the whole decision.
Your task: return country,
order_id and amount for each country's largest
paid order, breaking ties by the lower order_id, ordered
by country.
query.sql
PostgreSQLCtrl↵ to run
Hint
In the CTE: u.country, o.order_id, o.amount and ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC, o.order_id) AS rn, from orders joined to users where status = 'paid'. Then WHERE rn = 1.
Output
08
To do
NTILE(n) splits the window into n buckets of
as-equal-as-possible size and labels each row with its bucket number. It
is how quartiles, deciles and "top 20% of customers" get built.
NTILE(4) OVER (ORDER BY o.amount DESC) -- 1 = the biggest quarter
It divides by position, not by value. Rows with equal
values can land in different buckets, because NTILE's job is to make the
buckets the same size. If the boundary must respect ties, that is
RANK or an explicit range, not NTILE.
When the count does not divide evenly the earlier buckets get the extra
row each — 21 orders into 4 buckets gives 6, 5, 5, 5.
Your task: for paid orders, return
order_id, amount and a
quartile from 1 (largest) to 4, breaking ties by the lower
order_id. Order by amount descending, then order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
NTILE(4) OVER (ORDER BY o.amount DESC, o.order_id) AS quartile.
Output
09
To do
Module 3-05 asked for every order worth more than the average order in
its own country, and answered it with a correlated subquery that re-ran
once per row.
A window computes every country's average in one pass. The only wrinkle
is the rule from two stations ago — the comparison cannot live in
WHERE, so the window goes in a CTE and the filter goes
outside it.
Both are correct. The window version is usually faster, and it is
certainly easier to read once the shape is familiar. The correlated form
is worth keeping in hand for engines that do not support windows, which
by now are few.
Your task: return order_id,
country and amount for every order worth more
than the average order in its own country — using a window rather than a
correlated subquery. Order by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
In the CTE, add AVG(o.amount) OVER (PARTITION BY u.country) AS country_avg beside the columns, over orders joined to users with no status filter. Then WHERE amount > country_avg.
Output
The customer leaderboard
To do
A leaderboard is the natural home of a window function: every customer's
own number, their position against everyone else, and their share of the
whole — three levels on one row, which is exactly what
OVER is for.
Your task: one row for every user, with:
name — the user's name
country — their country
orders — how many paid orders they have, 0 if none
spent — their total paid amount, 0 rather than NULL if none
rank — their position by spent, highest first, with ties sharing a position
pct_of_total — their share of all paid revenue, rounded to one decimal place
Order by spent descending, then name.
Everything from Part 3 is still in force: three users have no paid orders
and must survive as zeros, so the join is an outer one with the paid
condition in the ON. Those three tie on 0, and the ranking
you were asked for is the one where a tie is shared rather than broken
arbitrarily.
query.sql
PostgreSQLCtrl↵ to run
Hint
The CTE is SELECT u.user_id, u.name, u.country, COUNT(o.order_id) AS orders, COALESCE(SUM(o.amount), 0) AS spent FROM users u LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid' GROUP BY u.user_id, u.name, u.country. Then RANK() OVER (ORDER BY spent DESC) and ROUND(100.0 * spent / SUM(spent) OVER (), 1).