Skip to content

Commit 7c5abae

Browse files
exekiasclaude
andauthored
Add lesson 18: Conditional expressions (#58)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 54c1b76 commit 7c5abae

4 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
So far your queries have returned column values as-is. Conditional expressions let you *compute* a value per row — bucket numbers into labels, swap NULL for something printable, or dodge a divide-by-zero — all inside the `SELECT` itself, no application code needed.
2+
3+
The seed is a tiny help desk: one `tickets` table, deliberately riddled with NULLs — unassigned tickets, missing contact details, unrated satisfaction scores.
4+
5+
<Run>
6+
SELECT * FROM tickets ORDER BY id;
7+
</Run>
8+
9+
## CASE: SQL's if/else
10+
11+
The searched form evaluates conditions top to bottom and returns the value of the **first** `WHEN` that's true. Perfect for bucketing a number into labels:
12+
13+
<Run>
14+
SELECT subject, satisfaction,
15+
CASE
16+
WHEN satisfaction >= 4 THEN 'happy'
17+
WHEN satisfaction >= 3 THEN 'neutral'
18+
WHEN satisfaction IS NOT NULL THEN 'unhappy'
19+
END AS mood
20+
FROM tickets
21+
ORDER BY id;
22+
</Run>
23+
24+
Notice the unrated tickets: no `WHEN` matched (every comparison with NULL is unknown), there's no `ELSE`, so the result is `NULL`. **A `CASE` without an `ELSE` yields NULL** — add `ELSE 'not rated yet'` and run it again to see the difference.
25+
26+
## The simple form: CASE expr WHEN value
27+
28+
When every branch compares the *same* expression against constants, the simple form is tidier — write the expression once, then list the values:
29+
30+
<Run>
31+
SELECT subject, status,
32+
CASE status
33+
WHEN 'open' THEN 'needs attention'
34+
WHEN 'pending' THEN 'waiting on customer'
35+
ELSE 'done'
36+
END AS next_step
37+
FROM tickets
38+
ORDER BY id;
39+
</Run>
40+
41+
One trap: the simple form compares with `=`, and `NULL = NULL` is not true. So `CASE col WHEN NULL THEN …` never matches — when NULL is one of your branches, use the searched form with `WHEN col IS NULL`.
42+
43+
## Custom sort orders
44+
45+
`ORDER BY priority` would sort alphabetically: `high`, `low`, `normal`, `urgent`. Useless. A `CASE` in the `ORDER BY` maps each value to the rank you actually want:
46+
47+
<Run>
48+
SELECT subject, priority, status
49+
FROM tickets
50+
WHERE status != 'closed'
51+
ORDER BY CASE priority
52+
WHEN 'urgent' THEN 1
53+
WHEN 'high' THEN 2
54+
WHEN 'normal' THEN 3
55+
WHEN 'low' THEN 4
56+
END;
57+
</Run>
58+
59+
The expression just computes a number per row, and the sort uses that. The same trick works in `GROUP BY` and `WHERE` — `CASE` is an expression, so it's welcome anywhere an expression is.
60+
61+
## Conditional aggregation: pivot-style counts
62+
63+
Combine aggregates with conditions and one query produces a status-by-assignee grid — each column counts only the rows matching its filter:
64+
65+
<Run>
66+
SELECT
67+
assignee,
68+
count(\*) FILTER (WHERE status = 'open') AS open,
69+
count(\*) FILTER (WHERE status = 'pending') AS pending,
70+
count(\*) FILTER (WHERE status = 'closed') AS closed
71+
FROM tickets
72+
GROUP BY assignee
73+
ORDER BY assignee;
74+
</Run>
75+
76+
`FILTER` is the idiomatic Postgres spelling. The portable classic does the same with `CASE`:
77+
78+
```sql
79+
sum(CASE WHEN status = 'open' THEN 1 ELSE 0 END) AS open
80+
```
81+
82+
Spot the last row: the unassigned ticket grouped under a NULL `assignee`. Let's make that presentable.
83+
84+
## COALESCE: the first non-NULL
85+
86+
`COALESCE(a, b, c, …)` returns its first non-NULL argument. The everyday use is replacing NULL with a display value:
87+
88+
<Run>
89+
SELECT
90+
COALESCE(assignee, 'unassigned') AS agent,
91+
count(*) AS tickets
92+
FROM tickets
93+
GROUP BY assignee
94+
ORDER BY tickets DESC;
95+
</Run>
96+
97+
It takes any number of arguments, so you can express a whole fallback chain — "phone if we have it, else email, else give up":
98+
99+
<Run>
100+
SELECT subject,
101+
COALESCE(customer_phone, customer_email, 'no contact info') AS reach_at
102+
FROM tickets
103+
ORDER BY id;
104+
</Run>
105+
106+
`COALESCE` also shines in `ORDER BY` — here unrated tickets sort as if they scored 0 instead of floating to one end:
107+
108+
<Run>
109+
SELECT subject, satisfaction
110+
FROM tickets
111+
ORDER BY COALESCE(satisfaction, 0) DESC;
112+
</Run>
113+
114+
(When you only need to control NULL placement, `ORDER BY satisfaction DESC NULLS LAST` is more direct — `COALESCE` is for when NULL should *count as* a value.) The same idea applies to join conditions: `ON COALESCE(a.col, '') = COALESCE(b.col, '')` lets two NULLs match where plain `=` never would.
115+
116+
## NULLIF: turn a value into NULL
117+
118+
`NULLIF(a, b)` is `COALESCE`'s inverse: it returns NULL when `a = b`, otherwise `a`. Its killer use is the divide-by-zero guard. One seed ticket has zero agent replies — a plain division would abort the whole query:
119+
120+
<Run>
121+
SELECT subject, messages, agent_replies,
122+
round(messages::numeric / NULLIF(agent_replies, 0), 1) AS msgs_per_reply
123+
FROM tickets
124+
ORDER BY id;
125+
</Run>
126+
127+
When `agent_replies` is `0`, `NULLIF` turns the divisor into NULL, the division yields NULL instead of an error, and every other row computes normally. Want a number instead of NULL? Compose them: `COALESCE(messages / NULLIF(agent_replies, 0), 0)`.
128+
129+
`NULLIF` is also handy for normalizing junk data — `NULLIF(trim(name), '')` converts empty strings into proper NULLs.
130+
131+
## GREATEST and LEAST
132+
133+
A brief but useful pair: they pick the largest or smallest of their arguments, row by row.
134+
135+
<Run>
136+
SELECT subject, satisfaction,
137+
LEAST(GREATEST(satisfaction, 2), 4) AS clamped
138+
FROM tickets
139+
WHERE satisfaction IS NOT NULL
140+
ORDER BY id;
141+
</Run>
142+
143+
That clamps every score into the 2–4 range — `GREATEST` sets the floor, `LEAST` the ceiling. Unusually for SQL, they *ignore* NULL arguments rather than propagating them; they only return NULL when every argument is NULL.
144+
145+
## Your turn
146+
147+
Build a per-priority summary: for each `priority`, show the total number of tickets and how many are still open, sorted from `urgent` down to `low`. You'll need conditional aggregation for the open count and a `CASE` for the sort. Try it before peeking — one solution:
148+
149+
<Run>
150+
SELECT
151+
priority,
152+
count(\*) AS total,
153+
count(\*) FILTER (WHERE status = 'open') AS still_open
154+
FROM tickets
155+
GROUP BY priority
156+
ORDER BY CASE priority
157+
WHEN 'urgent' THEN 1
158+
WHEN 'high' THEN 2
159+
WHEN 'normal' THEN 3
160+
WHEN 'low' THEN 4
161+
END;
162+
</Run>
163+
164+
## What you learned
165+
166+
- Searched `CASE WHEN cond THEN …` evaluates top to bottom; the first true branch wins, and no `ELSE` means NULL.
167+
- Simple `CASE expr WHEN value` compares with `=` — so it can never match NULL.
168+
- `CASE` is an expression: use it in `ORDER BY` for custom sort orders, or inside aggregates.
169+
- `count(*) FILTER (WHERE …)` (or `sum(CASE …)`) pivots categories into columns.
170+
- `COALESCE` returns the first non-NULL argument — fallback chains, display defaults, NULL-tolerant sorts and joins.
171+
- `NULLIF(a, b)` returns NULL when they're equal — the classic `value / NULLIF(divisor, 0)` guard.
172+
- `GREATEST`/`LEAST` pick extremes per row and, unusually, skip NULL arguments.
173+
174+
Up next: common table expressions — `WITH` queries that name intermediate results and untangle big SELECTs.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
title: Conditional expressions
2+
summary: Compute values per row with CASE, fall back through NULLs with COALESCE, and guard divisions with NULLIF.
3+
estimatedMinutes: 12
4+
tags:
5+
- case
6+
- coalesce
7+
- nullif
8+
- conditional
9+
- "null"
10+
authors:
11+
- exekias
12+
seed: seed.sql
13+
checks: []
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Seed for "01-conditional-expressions": a help-desk tickets table built to
2+
-- exercise CASE, COALESCE, and NULLIF. It's deliberately full of NULLs:
3+
-- unassigned tickets, missing contact details, unrated satisfaction scores,
4+
-- and one ticket with zero agent replies (the divide-by-zero trap).
5+
6+
CREATE TABLE tickets (
7+
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
8+
subject text NOT NULL,
9+
priority text NOT NULL, -- 'low' | 'normal' | 'high' | 'urgent'
10+
status text NOT NULL, -- 'open' | 'pending' | 'closed'
11+
assignee text, -- NULL = nobody picked it up yet
12+
customer_phone text,
13+
customer_email text,
14+
satisfaction int, -- 1-5, NULL until the customer rates
15+
messages int NOT NULL, -- total messages on the thread
16+
agent_replies int NOT NULL -- can be 0 (nobody replied yet)
17+
);
18+
19+
INSERT INTO tickets
20+
(subject, priority, status, assignee, customer_phone, customer_email, satisfaction, messages, agent_replies)
21+
VALUES
22+
('Password reset loop', 'high', 'closed', 'ada', NULL, 'sam@example.com', 5, 4, 2),
23+
('Billing charged twice', 'urgent', 'open', 'grace', '555-0100', NULL, NULL, 6, 3),
24+
('Dark mode flickers', 'low', 'closed', 'ada', NULL, 'lee@example.com', 3, 9, 4),
25+
('Export to CSV broken', 'normal', 'pending', 'grace', NULL, 'kim@example.com', NULL, 3, 1),
26+
('Cannot log in on mobile', 'urgent', 'open', NULL, '555-0101', 'pat@example.com', NULL, 1, 0),
27+
('Typo on pricing page', 'low', 'closed', 'grace', NULL, NULL, 4, 2, 1),
28+
('API returns 500 on upload', 'high', 'open', 'ada', NULL, 'dev@example.com', NULL, 7, 3),
29+
('Feature request: webhooks', 'normal', 'closed', 'ada', NULL, 'ona@example.com', 2, 5, 2);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
title: Intermediate querying
2+
difficulty: intermediate
3+
summary: Level up your SELECTs — conditional expressions, common table expressions, and window functions.

0 commit comments

Comments
 (0)