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.
What you'll knowWhy a computed value is invisible to any clause that runs before the clause that produced it.

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…”:

ValueBorn atWHERE (step 2) can see it?
SELECT alias6No → add a level
aggregate (max)3No → add a level, or use HAVING
window function5No → add a level
Diagnostic questionWhen stuck, ask: what step does my value come from, and what step needs it? If the need-step is earlier than the birth-step, you need another level.
02 The core move: compute low, filter high Push the computation into an inner query; filter on it in the outer one.
What you'll knowHow to turn a “can't filter on that” error into a working two-level query.

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 yet

Fixed

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 now

Same story for a window function — it's born at step 5, so WHERE (step 2) can't filter it. Postgres stops you cold:

no QUALIFY in Postgres
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 you'll knowHow to choose the right level-adding tool instead of reaching for a CTE by reflex.
What is a self-join?
The same table referenced twice under two aliases, so one row can look up a related row in the same table — e.g. a member and the member who recommended them. Use LEFT JOIN when the match is optional (“if any”).
Need another level — what does it do?
just looks up related rows
Self-join
Alias the table twice. LEFT if the match is optional.
transforms data (aggregate / rank / dedup)
CTE or subquery
CTE if the stage is a reusable, named concept; a subquery if it's a one-off.
needs the best row per group
Window function
Compute in a CTE, filter on the rank/row-number outside.
The common trapA lookup — a recommender's name, a parent row — is a self-join, not a CTE. CTEs are for the transform branch. Reaching for a CTE to wrap a lookup adds a layer that a second table-alias removes.

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.
What you'll knowWhy LIMIT 1 silently drops tied rows, and which ranking function keeps them.

The moment a question says “the most / the top / the last,” run this filter:

The askThe tool
latest, ties don't matterORDER BY … LIMIT 1
latest, keep tied rowsrank() OVER(...) = 1
top N per grouprow_number()/rank() OVER(PARTITION BY g ORDER BY …)
a group stat on every rowagg() OVER (PARTITION BY g)

The ranking family, as one picture

Functionvalues 90, 90, 80, 70Use when
row_number1 2 3 4pick exactly ONE per group (ties broken arbitrarily)
rank1 1 3 4keep ALL ties (gap after the tie)
dense_rank1 1 2 3distinct 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 member

rank() = 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 member

This 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.
What you'll knowWhy = NULL and NOT IN-with-nulls silently return nothing.
ExpressionWhat happens
= NULL / != NULLalways UNKNOWN → row dropped (silent bug)
IS NULL / IS NOT NULLthe only correct null test
NOT IN (…, NULL, …)always empty result (silent bug)
count(col) vs count(*)count(col) skips nulls; count(*) doesn't
RuleThe instant a column is nullable, switch from =/NOT IN thinking to IS/EXISTS thinking. NULL means “unknown,” and the database refuses to say whether an unknown equals anything — even another unknown.

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' || ' ' || surnameNULL
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;
PickUse concat_ws(' ', …) for names — null-safe and no trailing space when a part is missing. Reach for || only when neither column is nullable.
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.
What you'll knowWhy a non-grouped column errors, and the full menu of fixes — including when MIN/MAX is the wrong one.

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?

the error
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 forThe tool
one representative value, don't care whichMIN(b.memid) / MAX(b.memid)
it's the same for the whole groupadd it to GROUP BY
all the values in the grouparray_agg / string_agg
you don't want to collapse rowsagg() OVER (PARTITION BY …)
the whole row at the max/minDISTINCT 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.

The MIN/MAX trapYes, MAX(b.memid) clears the error — but per-column aggregates are computed independently, so max(memid) and max(starttime) may come from different rows. For the whole row at the maximum, use DISTINCT ON or row_number()=1, never per-column MAX.

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 error
The signalIf reducing the column to one value feels wrong, that's the tell you didn't want GROUP BY at all — you wanted a window function (card 04), which keeps the rows and sidesteps the error entirely.
07 Working with dates Quote the literal, and never use = on a timestamp — match a day with a half-open range.
What you'll knowThe two date bugs everyone writes, and the index-friendly way to match a whole day.

Bug 1 — an unquoted date is arithmetic

Broken

2012-09-14 = 2012 − 9 − 14 = 1989, an integer.

where starttime = 2012-09-14
-- ✕ compares to 1989

Fixed

Date & time literals are quoted strings.

where starttime = '2012-09-14'
-- ✓ a real date

Bug 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;
Why range, not caststarttime::date = '2012-09-14' works but casts every row, so the planner can't use an index. The half-open range compares the raw column — index-friendly and fast at scale. Keep predicates sargable.
BETWEEN trapbetween '2012-09-14' and '2012-09-15' is inclusive on both ends, so it also catches 2012-09-15 00:00:00 — a next-day booking. Prefer the half-open >= … < … for a whole day.

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 display
08 Three leverage points Small actions with outsized returns before the live pad.
What you'll knowThe three highest-return moves, in order, and why each matters.

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.

The axesLP1 = capability (can you solve it) · LP3 = perception (is it clean) · LP2 = reliability (is it right). Sequence: 1 → 2 → 3.
09 Proving a table's grain What does one row represent? Prove it with the same GROUP BY … HAVING move.
What you'll knowHow to prove what one row of a table stands for — its grain — in a single query.
What is “grain”?
The level of detail of a table — what a single row represents. Concretely, the minimal set of columns whose combination is unique. Get it wrong and joins fan out and you double-count.

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):

is memid the grain of cd.members?
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.

Why GROUP BY, not count(distinct)GROUP BY treats NULL = NULL, so it's null-safe; count(distinct) ignores nulls and can miss a duplicate key. It's the same move that found duplicate recommenders in card 03 — pointed at proving uniqueness instead.
10 Worked example: joins & the two dedup strategies Members who used a tennis court — dedup first (GROUP BY) vs dedup last (DISTINCT).
What you'll knowThe two ways to guarantee no duplicates across a multi-table join, and when each reads cleaner.

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;
StrategyHowReads cleaner when
dedup firstGROUP BY in a CTE, then joinyou want to state the grain (one row per court-member)
dedup lastjoin all, then DISTINCTquick, fewer lines — the live-pad default
Two rules in playconcat_ws keeps the name null-safe (card 05); and group by f.facid lets you select f.name because facid is the facilities primary key (card 06).
Edge caseDISTINCT on the formatted name would merge two different members who share a name; Approach A's group by … memid dedups by person, so it keeps them separate. Same-name collisions are where the two diverge.

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.
Was this useful, or did I get something wrong? Email me. I read every reply.
Continue Learning

Related study notes

More deep dives into SQL patterns — aggregation, window functions, and analytical pipelines.

Keep learning

Want to go deeper on SQL?

CNDRO publishes study notes that break down real query patterns — aggregation, window functions, and pipeline design.

Start a conversation