|
| 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. |
0 commit comments