CASE with multiple conditions
Short answer: CASE has two forms: simple (matches one column to literals) and searched (full predicates per branch). Use the searched form for any condition more complex than equality: ranges, multiple columns, AND / OR, IS NULL. Branches evaluate top-to-bottom and the first matching branch wins. Always include ELSE to handle uncovered cases.
The two forms #
-- Simple form: column matched against literals (equality only).
SELECT
id,
status,
CASE status
WHEN 'paid' THEN 'completed'
WHEN 'shipped' THEN 'completed'
WHEN 'pending' THEN 'in flight'
ELSE 'other'
END AS status_group
FROM orders;
-- Searched form: full predicates — any expression goes in WHEN.
SELECT
id,
status,
CASE
WHEN status IN ('paid', 'shipped') THEN 'completed'
WHEN status = 'pending' AND created_at < CURRENT_DATE - INTERVAL '7 days' THEN 'stale'
WHEN status = 'pending' THEN 'in flight'
WHEN created_at IS NULL THEN 'missing data'
ELSE 'other'
END AS status_group
FROM orders;Example #
Common patterns #
-- Bucket numeric ranges (price tiers, age groups).
CASE
WHEN total < 50 THEN 'small'
WHEN total < 200 THEN 'medium'
WHEN total < 1000 THEN 'large'
ELSE 'enterprise'
END
-- Cross-column conditions.
CASE
WHEN region = 'US' AND plan = 'pro' THEN 'us-pro'
WHEN region = 'US' AND plan = 'free' THEN 'us-free'
WHEN region = 'EU' THEN 'eu-' || plan
ELSE 'other'
END
-- "Coalesce-like" choosing between columns.
CASE
WHEN custom_email IS NOT NULL THEN custom_email
WHEN work_email IS NOT NULL THEN work_email
ELSE personal_email
END
-- Equivalent (and shorter): COALESCE(custom_email, work_email, personal_email)
-- Inside aggregates: count or sum conditionally.
SELECT
region,
SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END) AS paid_revenue,
COUNT(CASE WHEN total > 1000 THEN 1 END) AS big_orders -- NULL skipped
FROM orders
GROUP BY region;
-- Replace deprecated values.
SELECT
CASE old_status
WHEN 'pending' THEN 'in_review'
WHEN 'pending2' THEN 'in_review'
WHEN 'on_hold' THEN 'in_review'
WHEN 'archived' THEN 'closed'
ELSE old_status -- pass-through
END AS new_status
FROM legacy_orders;Common mistakes #
- Order-of-evaluation mistake:
WHEN total > 100 THEN 'medium'beforeWHEN total > 1000 THEN 'large'means every row > 100 (including > 1000) hits the first branch. Order narrowest-to-widest, or use range bounds (100 < total < 1000). - Forgetting
ELSE: rows that don't match anyWHENreturnNULL. Make this explicit withELSE NULLor, better,ELSE 'unknown'. - Type mismatch across branches:
THEN 1mixed withTHEN 'unknown'errors on most engines (can't reconcile to a single type). Cast or rephrase. - Equality with
NULL:WHEN col = NULLnever matches. UseWHEN col IS NULL— see NULL comparison. - Massively long
CASE: at 10+ branches, you have business logic embedded in SQL. Move to a lookup table joined into the query, where adding a new value is a row insert, not a SQL change.
Dialect notes #
CASEis ANSI standard, identical syntax across every major engine.- MySQL also has
IF(cond, then, else)as a 2-branch shortcut:IF(status = 'paid', 'done', 'pending')is equivalent toCASE WHEN status = 'paid' THEN 'done' ELSE 'pending' END. Not portable;CASEis preferred. - SQL Server has
IIFas a similar shortcut:IIF(status = 'paid', 'done', 'pending'). CASEinORDER BYandGROUP BYworks on every engine — useful for custom sorts and conditional grouping.- Postgres
FILTERclause:COUNT(*) FILTER (WHERE status = 'paid')is cleaner thanSUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END). Same result, less noise.
Once a CASE has more than ~6 branches, refactor. A long CASE chain encodes business rules that probably belong in a configuration table — status_mapping(old_status, new_status) joined into the query. Benefits: rules become data (editable without SQL changes), the same mapping is reusable across queries, and a missing case is a missing row instead of a silent NULL from the ELSE. The CASE is the right tool for inline conditional logic; for taxonomy and mapping, use a join. Detail: CASE.