How to use this page
Closed, the cards read as a scannable overview. Open one when you want the worked detail. The closing sections (misconceptions, what good looks like, glossary) stay open. Everything works without JavaScript.
A value must exist before the clause that uses it. If it isn't ready yet, add a level: compute it in an inner query, then filter or order by it in an outer one.
01 The clause clock: why your value isn't ready SQL runs clauses in a fixed order — that's the whole reason you add levels.
SQL clauses execute in a fixed logical order — not the order you write them:
A value born at step N is invisible to every step before it. That single fact explains every “why can't I…”:
| Value | Born at | WHERE (step 2) can see it? |
|---|---|---|
| SELECT alias | 6 | No → add a level |
| aggregate (max) | 3 | No → add a level, or use HAVING |
| window function | 5 | No → add a level |
02 The core move: compute low, filter high Push the computation into an inner query; filter on it in the outer one.
You want to filter on a ratio you computed in SELECT. It doesn't exist yet when WHERE runs, so you move it down a level:
Broken
Filtering a SELECT alias in the same WHERE.
select facid, name,
monthlymaintenance / nullif(membercost,0) as ratio
from cd.facilities
where ratio > 50 -- ✕ ratio not born yetFixed
Compute in a CTE, filter outside it.
with r as (
select facid, name,
monthlymaintenance / nullif(membercost,0) as ratio
from cd.facilities
)
select facid, name from r
where ratio > 50; -- ✓ ratio is a column nowSame story for a window function — it's born at step 5, so WHERE (step 2) can't filter it. Postgres stops you cold:
select facid, name from cd.facilities
where rank() over (order by monthlymaintenance desc) <= 3;
-- ✕ ERROR: window functions are not allowed in WHERE
Move it into a CTE, filter on the resulting column:
with ranked as (
select facid, name,
rank() over (order by monthlymaintenance desc) as rnk
from cd.facilities
)
select facid, name from ranked where rnk <= 3;03 Which level? Self-join vs CTE vs window Pick the tool by what the inner level actually does.
What is a self-join?
The lookup done right — one table, two roles:
select m.firstname, m.surname,
r.firstname as rec_first, r.surname as rec_last
from cd.members m
left join cd.members r on r.memid = m.recommendedby
order by m.surname, m.firstname;04 Ties & ranking: the interview differentiator One, one-per-group, or ties? Pick row_number / rank / dense_rank accordingly.
The moment a question says “the most / the top / the last,” run this filter:
| The ask | The tool |
|---|---|
| latest, ties don't matter | ORDER BY … LIMIT 1 |
| latest, keep tied rows | rank() OVER(...) = 1 |
| top N per group | row_number()/rank() OVER(PARTITION BY g ORDER BY …) |
| a group stat on every row | agg() OVER (PARTITION BY g) |
The ranking family, as one picture
| Function | values 90, 90, 80, 70 | Use when |
|---|---|---|
| row_number | 1 2 3 4 | pick exactly ONE per group (ties broken arbitrarily) |
| rank | 1 1 3 4 | keep ALL ties (gap after the tie) |
| dense_rank | 1 1 2 3 | distinct ranking levels (no gap) |
LIMIT 1 — the trap
“The last member(s)” with a tie on join date:
order by joindate desc
limit 1 -- ✕ drops the tied memberrank() = 1 — correct
All rows tied for the latest date share rank 1:
where rnk = 1
-- rnk = rank() over
-- (order by joindate desc)
-- ✓ keeps every tied memberThis is the tool the pre-window kit can't replace: subquery-with-max, IN, and distinct all work until “per group” or “keep ties” enters the question — then only a window function is clean.
05 NULL is “unknown,” not “empty” Test with IS, never = — the NOT IN kill-shot, and null-safe string concat.
| Expression | What happens |
|---|---|
| = NULL / != NULL | always UNKNOWN → row dropped (silent bug) |
| IS NULL / IS NOT NULL | the only correct null test |
| NOT IN (…, NULL, …) | always empty result (silent bug) |
| count(col) vs count(*) | count(col) skips nulls; count(*) doesn't |
String concatenation & NULL
The same propagation bites when you join a first and last name. The || operator turns the whole result NULL if any piece is null; the concat functions don't:
| Expression (surname is NULL) | Result |
|---|---|
| 'Bob' || ' ' || surname | NULL |
| concat('Bob', ' ', surname) | 'Bob ' |
| concat_ws(' ', 'Bob', surname) | 'Bob' |
-- best for names: null-safe, drops the separator for a missing part
select concat_ws(' ', firstname, surname) as full_name
from cd.members;06 GROUP BY's one-value rule “must appear in the GROUP BY or be used in an aggregate” — and what to do besides adding it.
The error means one thing: GROUP BY collapses rows, so a non-grouped column has many possible values per group and SQL refuses to guess which to show. Every fix answers the same question — what do you actually want that column for?
ERROR: column "b.memid" must appear in the GROUP BY
clause or be used in an aggregate function
The menu — pick by intent
| What you want the column for | The tool |
|---|---|
| one representative value, don't care which | MIN(b.memid) / MAX(b.memid) |
| it's the same for the whole group | add it to GROUP BY |
| all the values in the group | array_agg / string_agg |
| you don't want to collapse rows | agg() OVER (PARTITION BY …) |
| the whole row at the max/min | DISTINCT ON · row_number()=1 |
Grouping by a table's primary key also unlocks its other columns for free (functional dependency) — e.g. group by b.bookid lets you select b.memid ungrouped, because the PK determines it.
Keep every value, or don't collapse at all
-- collect the values instead of picking one
select b.facid, array_agg(b.memid) as members
from cd.bookings b group by b.facid;
-- or don't collapse — a window function keeps every row
select b.memid, b.starttime,
count(*) over (partition by b.facid) as bookings_here
from cd.bookings b; -- no GROUP BY, no error07 Working with dates Quote the literal, and never use = on a timestamp — match a day with a half-open range.
Bug 1 — an unquoted date is arithmetic
Broken
2012-09-14 = 2012 − 9 − 14 = 1989, an integer.
where starttime = 2012-09-14
-- ✕ compares to 1989Fixed
Date & time literals are quoted strings.
where starttime = '2012-09-14'
-- ✓ a real dateBug 2 — timestamp ≠ date
starttime carries a time, so = '2012-09-14' means '2012-09-14 00:00:00' — it matches only bookings at midnight. To get every booking on that day, use a half-open range:
select facid, starttime
from cd.bookings
where starttime >= '2012-09-14'
and starttime < '2012-09-15' -- the day, up to (not incl.) the next
order by starttime;The toolkit
current_date -- today
now() / current_timestamp -- this instant
starttime + interval '1 day' -- date math
extract(hour from starttime) -- pull a piece out
date_trunc('day', starttime) -- floor to midnight
to_char(starttime, 'YYYY-MM-DD') -- format for display08 Three leverage points Small actions with outsized returns before the live pad.
1 · Drill partition by + the rank family to reflex
Small action: memorise the 1-2-3 / 1-1-3 / 1-1-2 picture; write ~10 top-N-per-group queries. Outsized result: it converts the entire pre-window toolkit from “works sometimes” to “works always,” and it's the pattern these problems are actually testing. Do first.
2 · A 10-second pre-submit checklist
Small action: before saying “done,” check — (1) ORDER BY on the outermost query, spec columns in exact order? (2) commas between CTEs, none before the final SELECT? (3) honoured every spec word — “(s),” “if any,” “no duplicates”? Outsized result: it kills the entire class of last-mile bugs — a habit, not new knowledge.
3 · Install one reflex: “is this a self-join?”
Small action: before writing a CTE, ask “am I just looking up related rows?” → alias the table twice instead. Outsized result: corrects the recurring over-build; fewer layers read as cleaner modeling and leave fewer places for bugs.
09 Proving a table's grain What does one row represent? Prove it with the same GROUP BY … HAVING move.
What is “grain”?
To test whether a column (or set of columns) is the grain, group by it and look for any group holding more than one row. Zero rows back means it's unique — that column set is the grain (or finer):
select memid, count(*)
from cd.members
group by memid
having count(*) > 1; -- 0 rows → memid IS the grain
0 rows returned
No group has a duplicate → memid uniquely identifies a row. One row = one member. That's the grain.
Any rows returned
Those are your duplicates → the column is not the grain. Add another column and test the pair the same way.
10 Worked example: joins & the two dedup strategies Members who used a tennis court — dedup first (GROUP BY) vs dedup last (DISTINCT).
The task: list members who have used a tennis court, with the court name and the member's name as a single column, no duplicates, ordered by member then facility.
Approach A — dedup first (GROUP BY in a CTE)
with tennis as (
select f.facid, b.memid, f.name
from cd.facilities f
join cd.bookings b on b.facid = f.facid
where f.name like 'Tennis%'
group by f.facid, b.memid -- one row per (court, member); name via PK dependency
)
select concat_ws(' ', m.firstname, m.surname) as member, t.name as facility
from cd.members m
join tennis t on t.memid = m.memid
order by member, facility;Approach B — dedup last (triple join + DISTINCT)
select distinct concat_ws(' ', m.firstname, m.surname) as member,
f.name as facility
from cd.members m
join cd.bookings b on b.memid = m.memid
join cd.facilities f on f.facid = b.facid
where f.name like 'Tennis%'
order by member, facility;| Strategy | How | Reads cleaner when |
|---|---|---|
| dedup first | GROUP BY in a CTE, then join | you want to state the grain (one row per court-member) |
| dedup last | join all, then DISTINCT | quick, fewer lines — the live-pad default |
11Common misconceptions
-
“Defaulting to CTEs is best practice.”No — a CTE should name a concept. A lookup is a self-join; a simple filter may just be a subquery. Since Postgres 12 a CTE is inlined by the planner, but on older engines it was an optimization fence (always materialised), which could hurt performance.
-
“ORDER BY inside a CTE sorts the result.”Not guaranteed. The planner may reorder rows when the outer query reads the CTE. Put ORDER BY on the outermost query; an inner one is only meaningful paired with LIMIT.
-
“LIMIT 1 gives me the max.”Only one row, ties be damned. For “the latest, including ties,” use rank() = 1.
-
“= NULL finds the nulls.”Never true — it returns nothing. Use IS NULL / IS NOT NULL.
-
“UNION and UNION ALL are interchangeable.”UNION runs a dedup pass (sort/hash) over the whole combined set; UNION ALL just concatenates. Default to UNION ALL unless you need duplicates removed.
12What good understanding looks like
- You can name the clause that produces a value and the clause that needs it — and add a level when the need comes first.
- You reach for a self-join on lookups, a CTE/subquery on transforms, and a window function on best-per-group — by reflex.
- You pick rank vs row_number vs dense_rank from the tie requirement, not habit.
- You test nulls with IS, never =, and you're wary of NOT IN over a nullable column.
- You put ORDER BY on the outermost query, matching the spec's columns in exact order.
13Glossary
- Logical evaluation order
- The fixed sequence the database evaluates clauses in (FROM → WHERE → GROUP BY → HAVING → window → SELECT → ORDER BY → LIMIT), regardless of the order you type them.
- Window function
- A function computed across a set of rows related to the current row (via OVER) that keeps every row, unlike GROUP BY, which collapses them.
- PARTITION BY
- The OVER clause that splits rows into groups; the window function restarts for each partition.
- CTE (Common Table Expression)
- A named inner query defined with WITH. Best used to name a transformation step you then build on.
- Self-join
- Joining a table to itself under two aliases so a row can reference a related row in the same table.
- Optimization fence
- A boundary the planner won't optimise across. Pre-Postgres-12 CTEs were always materialised as a fence; since 12 they are inlined by default.
- Tie
- Two or more rows sharing the same ordering value. rank keeps them together; row_number breaks them arbitrarily.