Module 5 · Intermediate querying
Sign in with email or GitHub to spin up your own Postgres sandbox and run the queries for this lesson.
Sign in to start the sandboxNo GitHub? Create an account with email.
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.
The seed is a tiny help desk: one tickets table, deliberately riddled with NULLs — unassigned tickets, missing contact details, unrated satisfaction scores.
SELECT * FROM tickets ORDER BY id;
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:
SELECT subject, satisfaction,
CASE
WHEN satisfaction >= 4 THEN 'happy'
WHEN satisfaction >= 3 THEN 'neutral'
WHEN satisfaction IS NOT NULL THEN 'unhappy'
END AS mood
FROM tickets
ORDER BY id;
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.
When every branch compares the same expression against constants, the simple form is tidier — write the expression once, then list the values:
SELECT subject, status,
CASE status
WHEN 'open' THEN 'needs attention'
WHEN 'pending' THEN 'waiting on customer'
ELSE 'done'
END AS next_step
FROM tickets
ORDER BY id;
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.
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:
SELECT subject, priority, status
FROM tickets
WHERE status != 'closed'
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
WHEN 'low' THEN 4
END;
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.
Combine aggregates with conditions and one query produces a status-by-assignee grid — each column counts only the rows matching its filter:
SELECT
assignee,
count(*) FILTER (WHERE status = 'open') AS open,
count(*) FILTER (WHERE status = 'pending') AS pending,
count(*) FILTER (WHERE status = 'closed') AS closed
FROM tickets
GROUP BY assignee
ORDER BY assignee;
FILTER is the idiomatic Postgres spelling. The portable classic does the same with CASE:
sum(CASE WHEN status = 'open' THEN 1 ELSE 0 END) AS open
Spot the last row: the unassigned ticket grouped under a NULL assignee. Let's make that presentable.
COALESCE(a, b, c, …) returns its first non-NULL argument. The everyday use is replacing NULL with a display value:
SELECT
COALESCE(assignee, 'unassigned') AS agent,
count(*) AS tickets
FROM tickets
GROUP BY assignee
ORDER BY tickets DESC;
It takes any number of arguments, so you can express a whole fallback chain — "phone if we have it, else email, else give up":
SELECT subject,
COALESCE(customer_phone, customer_email, 'no contact info') AS reach_at
FROM tickets
ORDER BY id;
COALESCE also shines in ORDER BY — here unrated tickets sort as if they scored 0 instead of floating to one end:
SELECT subject, satisfaction
FROM tickets
ORDER BY COALESCE(satisfaction, 0) DESC;
(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.
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:
SELECT subject, messages, agent_replies,
round(messages::numeric / NULLIF(agent_replies, 0), 1) AS msgs_per_reply
FROM tickets
ORDER BY id;
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).
NULLIF is also handy for normalizing junk data — NULLIF(trim(name), '') converts empty strings into proper NULLs.
A brief but useful pair: they pick the largest or smallest of their arguments, row by row.
SELECT subject, satisfaction,
LEAST(GREATEST(satisfaction, 2), 4) AS clamped
FROM tickets
WHERE satisfaction IS NOT NULL
ORDER BY id;
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.
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, and save the result as a table called priority_summary (CREATE TABLE … AS stores any query's result). You'll need conditional aggregation for the open count and a CASE for the sort. Try it before peeking — one solution:
CREATE TABLE priority_summary AS
SELECT
priority,
count(*) AS total,
count(*) FILTER (WHERE status = 'open') AS still_open
FROM tickets
GROUP BY priority
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
WHEN 'low' THEN 4
END;
Have a look at what landed:
SELECT * FROM priority_summary;
Four rows, one per priority, with urgent on top — every ticket counted, and only the genuinely open ones in still_open.
CASE WHEN cond THEN … evaluates top to bottom; the first true branch wins, and no ELSE means NULL.CASE expr WHEN value compares with = — so it can never match NULL.CASE is an expression: use it in ORDER BY for custom sort orders, or inside aggregates.count(*) FILTER (WHERE …) (or sum(CASE …)) pivots categories into columns.COALESCE returns the first non-NULL argument — fallback chains, display defaults, NULL-tolerant sorts and joins.NULLIF(a, b) returns NULL when they're equal — the classic value / NULLIF(divisor, 0) guard.GREATEST/LEAST pick extremes per row and, unusually, skip NULL arguments.Up next: common table expressions — WITH queries that name intermediate results and untangle big SELECTs.