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.
So far every column has either been stored or computed by arithmetic.
CASE lets a column depend on a condition — turning a price into
a tier, a flag into a word, a NULL into a category. It is the tool that
turns raw columns into the categories a report is actually about.
Ready?
1
CASE Is an Expression, Not a Statement
A CASE produces a value. That is worth saying first, because
it means a CASE can go anywhere a column can go — in the
SELECT list, inside WHERE, inside
ORDER BY, and inside an aggregate function.
SELECT name,
price,
CASE
WHEN price >= 100 THEN 'premium'
WHEN price >= 25 THEN 'standard'
ELSE 'entry'
END AS tier
FROM products;
Read it as a list of questions asked in order. Is the price at least 100?
Then the value is 'premium' and we stop. Otherwise, is it at
least 25? And so on, with ELSE catching everything left.
END closes it, and AS tier names the column it
produced.
There is a shorter form for testing one expression against several
values, which only does equality:
CASE is_active
WHEN 1 THEN 'active'
ELSE 'churned'
END
Quick check
Where can a CASE expression appear?
2
Branch Order Is the Specification
Evaluation stops at the first branch that is true.
Everything below it is never even considered. That is what lets the
second branch in the price example say simply
WHEN price >= 25 — anything reaching it has already
failed the 100 test, so "and under 100" is implied.
Reverse the two branches and every premium product is labelled
standard, because 250 is also at least 25 and that branch
now comes first. Nothing errors. The report is simply wrong.
-- Wrong: nothing is ever premium
CASE
WHEN price >= 25 THEN 'standard'
WHEN price >= 100 THEN 'premium'
ELSE 'entry'
END
The rule that prevents it
Write branches from most specific to least. A
catch-all sitting above a special case silently swallows it, and the
only symptom is a category nobody ever lands in — which is exactly the
kind of thing that survives a review.
The same rule governs priority rules that come from a stakeholder. "A
churned team-plan customer is churned, not enterprise" is not extra
logic — it is a statement about which branch goes first.
Quick check
A CASE lists WHEN plan = 'team' THEN 'enterprise' before WHEN NOT is_active THEN 'churned'. What happens to a churned team-plan user?
3
No ELSE Means NULL
ELSE is optional, and leaving it out has a specific
consequence: a row matching no branch gets NULL. After the
last module you know exactly how much trouble that causes downstream.
CASE
WHEN plan = 'pro' THEN 'growth'
WHEN plan = 'team' THEN 'enterprise'
END
-- every free user gets NULL
The temptation is to say the branches are exhaustive so it cannot happen.
Sometimes that is true today. It stops being true the moment somebody
adds a fourth plan, and the bucketing column starts producing NULLs in a
report that nobody re-reads.
Write the ELSE. If you genuinely have no sensible category,
make that explicit — ELSE 'other' or
ELSE 'unclassified' — so the unexpected row shows up as a
visible bucket rather than as a blank.
Silent
No ELSE
Unmatched rows come back NULL and quietly drop out of counts and comparisons.
Visible
ELSE 'other'
The unexpected row appears as a bucket you can see and go and investigate.
Quick check
A CASE with two WHEN branches and no ELSE runs over 12 rows, 5 of which match nothing. What comes back for those 5?
4
A Number From One Branch, Zero From the Other
A CASE does not have to return text. Returning a value from
one branch and 0 from the other is the most useful CASE
pattern in all of analytics:
CASE WHEN status = 'paid' THEN amount ELSE 0 END
On its own that is mildly interesting. Wrapped in an aggregate in Part 2
it becomes something a WHERE clause cannot do at all — two
different filters, side by side, in one query:
SELECT SUM(amount) AS booked,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS recognised
FROM orders;
A WHERE status = 'paid' would have filtered the whole query
and lost the first number. This is called conditional
aggregation, and it is how one query produces a whole row of
metrics that each count something slightly different.
Where this is going
Part 2 is aggregation: counting, summing and grouping. Almost every
real metric there — conversion rate, paid share, active-user
percentage — is a SUM or an AVG wrapped
around a CASE exactly like this one.
Get comfortable with this shape now and half of Part 2 is already familiar.
Quick check
Why compute recognised revenue with a CASE rather than with WHERE status = 'paid'?
0 of 4 completed
Loading the tables…
01
To do
CASE is SQL's if-statement. It walks its branches from the
top and stops at the first one that is true:
CASE WHEN price >= 100 THEN 'premium' WHEN price >= 25 THEN 'standard' ELSE 'entry' END AS tier
Because it stops at the first match, the second branch never has to say
"and under 100" — anything reaching it has already failed the first
test. Writing the branches from most specific to least is what makes
that work.
Your task: return name,
price, and a column named tier that reads
premium at 100 or more, standard at 25 or
more, and entry below that.
query.sql
PostgreSQLCtrl↵ to run
Hint
Two WHEN branches and an ELSE, in that order: WHEN price >= 100 THEN 'premium', WHEN price >= 25 THEN 'standard', ELSE 'entry'.
Output
02
To do
A CASE branch can hold any condition a WHERE
can, including IS NULL. That makes it the readable way to
turn a missing value into a category rather than a blank.
ELSE is optional, and leaving it out is a trap: a row that
matches no branch gets NULL, which is usually the opposite
of what a bucketing exercise was for. Write the ELSE.
Your task: return name and a column named
account_type reading individual where the
user has no company and company where they do.
query.sql
PostgreSQLCtrl↵ to run
Hint
CASE WHEN company IS NULL THEN 'individual' ELSE 'company' END AS account_type — one branch and an ELSE is enough.
Output
03
To do
is_active is a boolean: it holds
true or false, not 1 or 0. Postgres has a real
boolean type, which means a WHEN can test the column
directly — there is nothing to compare it to.
CASE WHEN is_active THEN 'active' ELSE 'churned' END
WHEN is_active = true also works and is just noise; the
column is already the condition. For the other side, write
WHEN NOT is_active rather than comparing against false.
Databases without a boolean type store flags as 1 and 0,
and is_active = 1 is what you write there. Postgres rejects
that outright — operator does not exist: boolean = integer —
because a truth value and a number are genuinely different things, and
it would rather say so than guess.
Your task: return name, plan
and a column named state reading active when
is_active is true and churned otherwise.
query.sql
PostgreSQLCtrl↵ to run
Hint
CASE WHEN is_active THEN 'active' ELSE 'churned' END AS state — the boolean column needs no comparison at all.
Output
04
To do
A CASE does not have to produce text. Returning a number
from one branch and zero from another is how you conditionally include a
value in a total:
CASE WHEN status = 'paid' THEN amount ELSE 0 END
This is the single most useful CASE pattern in analytics. Wrapped in
SUM() in Part 2 it becomes "revenue from paid orders only,
alongside the total" — two different filters in one query, which a
WHERE cannot do.
Your task: return order_id,
amount, status, and a column named
recognised holding the amount for paid orders and
0 for everything else.
query.sql
PostgreSQLCtrl↵ to run
Hint
CASE WHEN status = 'paid' THEN amount ELSE 0 END AS recognised — the THEN returns a column, not a literal.
Output
The segment map
To do
Marketing want every user tagged with one segment, and they have given you
the rules in priority order. A user gets the first tag
that applies:
churned is_active is false, whatever their plan enterprise on the team plan growth on the pro plan starter everyone else
The order is the specification. A churned user on the team plan is
churned, not enterprise — which is exactly the
kind of thing a CASE gets right for free and a pile of separate conditions
gets wrong.
Your task: return name, plan,
country and a fourth column named segment,
sorted by user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
Put WHEN NOT is_active THEN 'churned' first, then the two plan branches, then ELSE 'starter'. Finish with ORDER BY user_id.