CASE Statement in SQL
Here's the thing about CASE WHEN. You're going to write it more than almost any other SQL construct in your first year as a data engineer, and nobody ever sits you down and teaches it properly. You pick up the syntax in pieces, you get something working, and then an interviewer asks you to bucket revenue into five tiers under a three minute timer and your brain locks. That's what this page is for.
Simple CASE Syntax
Know CASE statements the way the interviewer who asks it knows it.
-- Custom sort order using simple CASE
SELECT task_name, priority
FROM tasks
ORDER BY
CASE priority
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END;When to Use Simple CASE
-- Searched CASE: each WHEN is an independent condition
SELECT
employee_id,
name,
salary,
CASE
WHEN salary >= 150000 THEN 'Executive'
WHEN salary >= 100000 THEN 'Senior'
WHEN salary >= 70000 THEN 'Mid-Level'
WHEN salary >= 40000 THEN 'Junior'
ELSE 'Entry'
END AS salary_band
FROM employees;Searched CASE Syntax
-- Classify customers by both spend and tenure
SELECT
customer_id,
total_spend,
months_active,
CASE
WHEN total_spend >= 10000 AND months_active >= 24
THEN 'VIP'
WHEN total_spend >= 5000 OR months_active >= 12
THEN 'Established'
WHEN months_active >= 3
THEN 'Growing'
ELSE 'New'
END AS customer_tier
FROM customers;-- NULL-safe CASE
SELECT
order_id,
shipped_date,
CASE
WHEN shipped_date IS NULL THEN 'Not Shipped'
WHEN shipped_date > CURRENT_DATE - INTERVAL '7 days'
THEN 'Recently Shipped'
ELSE 'Shipped'
END AS shipping_status
FROM orders;
-- WRONG: this never matches NULL
-- CASE shipped_date WHEN NULL THEN 'Not Shipped' END
-- NULL = NULL evaluates to NULL (not TRUE), so it skipsMulti-Column Conditions
-- Conditional count: orders by status in one query
SELECT
DATE_TRUNC('month', order_date) AS order_month,
COUNT(*) AS total_orders,
COUNT(CASE WHEN status = 'delivered' THEN 1 END) AS delivered,
COUNT(CASE WHEN status = 'returned' THEN 1 END) AS returned,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY order_month;NULL Handling in CASE
-- Conditional sum: revenue by region in one query
SELECT
product_category,
SUM(CASE WHEN region = 'North' THEN revenue ELSE 0 END) AS north_revenue,
SUM(CASE WHEN region = 'South' THEN revenue ELSE 0 END) AS south_revenue,
SUM(CASE WHEN region = 'East' THEN revenue ELSE 0 END) AS east_revenue,
SUM(CASE WHEN region = 'West' THEN revenue ELSE 0 END) AS west_revenue,
SUM(revenue) AS total_revenue
FROM sales
GROUP BY product_category;CASE with Aggregation: Conditional Counting and Summing
Extreme Category Totals
The highest and the lowest. Both are interesting.
Pulled from debriefs where SQL was the gate.
-- Running total of delivered revenue only
SELECT
order_id,
order_date,
status,
revenue,
SUM(CASE WHEN status = 'delivered' THEN revenue ELSE 0 END)
OVER (ORDER BY order_date) AS running_delivered_revenue
FROM orders
ORDER BY order_date;-- Flag rows where an employee's salary is above department average
SELECT
employee_id,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
CASE
WHEN salary > AVG(salary) OVER (PARTITION BY department)
THEN 'Above Average'
WHEN salary = AVG(salary) OVER (PARTITION BY department)
THEN 'At Average'
ELSE 'Below Average'
END AS salary_position
FROM employees;CASE with AVG and Ratios
-- Simple CASE: compare one expression to fixed values
SELECT
order_id,
status,
CASE status
WHEN 'pending' THEN 'Awaiting Processing'
WHEN 'shipped' THEN 'In Transit'
WHEN 'delivered' THEN 'Complete'
WHEN 'returned' THEN 'Returned to Warehouse'
ELSE 'Unknown'
END AS status_label
FROM orders;-- Classify change direction from previous row
SELECT
date,
metric_value,
LAG(metric_value) OVER (ORDER BY date) AS prev_value,
CASE
WHEN metric_value > LAG(metric_value) OVER (ORDER BY date)
THEN 'Increase'
WHEN metric_value < LAG(metric_value) OVER (ORDER BY date)
THEN 'Decrease'
WHEN metric_value = LAG(metric_value) OVER (ORDER BY date)
THEN 'No Change'
ELSE 'N/A' -- first row has no previous value
END AS direction
FROM daily_metrics;CASE with Window Functions
Data Cleaning: Standardize Messy Input
-- Standardize country names from raw data
SELECT
user_id,
CASE
WHEN UPPER(TRIM(country)) IN ('US', 'USA', 'UNITED STATES', 'U.S.A.')
THEN 'United States'
WHEN UPPER(TRIM(country)) IN ('UK', 'GB', 'UNITED KINGDOM', 'GREAT BRITAIN')
THEN 'United Kingdom'
WHEN UPPER(TRIM(country)) IN ('CA', 'CAN', 'CANADA')
THEN 'Canada'
ELSE INITCAP(TRIM(country))
END AS country_standardized
FROM raw_users;SCD Type 2 Flag
-- Mark current vs historical records
SELECT
customer_id,
name,
email,
valid_from,
valid_to,
CASE
WHEN valid_to IS NULL OR valid_to > CURRENT_TIMESTAMP
THEN TRUE
ELSE FALSE
END AS is_current
FROM customer_history;Data Quality Scoring
-- Score row completeness for a data quality dashboard
SELECT
record_id,
(
CASE WHEN email IS NOT NULL AND email != '' THEN 1 ELSE 0 END
+ CASE WHEN phone IS NOT NULL AND phone != '' THEN 1 ELSE 0 END
+ CASE WHEN address IS NOT NULL AND address != '' THEN 1 ELSE 0 END
+ CASE WHEN name IS NOT NULL AND name != '' THEN 1 ELSE 0 END
) AS completeness_score,
CASE
WHEN (
CASE WHEN email IS NOT NULL AND email != '' THEN 1 ELSE 0 END
+ CASE WHEN phone IS NOT NULL AND phone != '' THEN 1 ELSE 0 END
+ CASE WHEN address IS NOT NULL AND address != '' THEN 1 ELSE 0 END
+ CASE WHEN name IS NOT NULL AND name != '' THEN 1 ELSE 0 END
) = 4 THEN 'Complete'
WHEN (
CASE WHEN email IS NOT NULL AND email != '' THEN 1 ELSE 0 END
+ CASE WHEN phone IS NOT NULL AND phone != '' THEN 1 ELSE 0 END
+ CASE WHEN address IS NOT NULL AND address != '' THEN 1 ELSE 0 END
+ CASE WHEN name IS NOT NULL AND name != '' THEN 1 ELSE 0 END
) >= 2 THEN 'Partial'
ELSE 'Poor'
END AS quality_tier
FROM contacts;CASE in Data Engineering Pipelines
Pattern 1: Bucketing Continuous Values
"Group users by age bracket" or "categorize orders by amount." Use searched CASE with range conditions. List ranges from largest to smallest (or smallest to largest) to avoid overlaps.
SELECT
CASE
WHEN age >= 65 THEN '65+'
WHEN age >= 45 THEN '45-64'
WHEN age >= 25 THEN '25-44'
WHEN age >= 18 THEN '18-24'
ELSE 'Under 18'
END AS age_group,
COUNT(*) AS user_count
FROM users
GROUP BY 1
ORDER BY MIN(age);Pattern 2: Conditional Aggregation (Pivot)
"Show each department's headcount broken out by gender." Use CASE inside COUNT or SUM to create columns from row values.
SELECT
department,
COUNT(CASE WHEN gender = 'M' THEN 1 END) AS male_count,
COUNT(CASE WHEN gender = 'F' THEN 1 END) AS female_count,
COUNT(CASE WHEN gender NOT IN ('M', 'F') THEN 1 END) AS other_count,
COUNT(*) AS total
FROM employees
GROUP BY department;Pattern 3: Decode Boolean Flags
Converting 0/1, true/false, or Y/N into readable labels for reporting.
SELECT
user_id,
CASE is_verified
WHEN TRUE THEN 'Verified'
WHEN FALSE THEN 'Unverified'
END AS verification_status,
CASE has_subscription
WHEN TRUE THEN 'Subscriber'
WHEN FALSE THEN 'Free Tier'
END AS subscription_status
FROM users;Pattern 4: Safe Division
Prevent division-by-zero errors in ratio calculations.
SELECT
campaign_id,
impressions,
clicks,
CASE
WHEN impressions = 0 THEN 0
ELSE ROUND(100.0 * clicks / impressions, 2)
END AS click_through_rate
FROM ad_campaigns;CASE Statement Patterns for Interviews
Overlapping Conditions
Because CASE stops at the first match, overlapping ranges silently produce wrong results.
-- BUG: salary of 120000 matches first condition
CASE
WHEN salary > 50000 THEN 'Mid' -- 120000 > 50000 is TRUE
WHEN salary > 100000 THEN 'Senior' -- never reached for 120000
END
-- FIX: order from most restrictive to least
CASE
WHEN salary > 100000 THEN 'Senior'
WHEN salary > 50000 THEN 'Mid'
ELSE 'Junior'
ENDMissing ELSE Returns NULL
Forgetting ELSE when you do not want NULL results.
-- BUG: status 'canceled' returns NULL
SELECT CASE status
WHEN 'active' THEN 'Active'
WHEN 'inactive' THEN 'Inactive'
END AS label -- 'canceled' -> NULL
-- FIX: always handle the default
SELECT CASE status
WHEN 'active' THEN 'Active'
WHEN 'inactive' THEN 'Inactive'
ELSE 'Other'
END AS labelMixing Data Types in THEN Clauses
All THEN and ELSE values must be the same data type (or implicitly castable). Mixing strings and numbers causes errors in strict-type databases.
-- BUG in strict-type engines: mixing int and text
CASE WHEN quantity > 0 THEN quantity ELSE 'N/A' END
-- FIX: cast to consistent type
CASE WHEN quantity > 0 THEN CAST(quantity AS TEXT) ELSE 'N/A' ENDCommon CASE Statement Mistakes
-- Delivery rate by month
SELECT
DATE_TRUNC('month', order_date) AS month,
ROUND(
100.0
* COUNT(CASE WHEN status = 'delivered' THEN 1 END)
/ NULLIF(COUNT(*), 0),
1
) AS delivery_rate_pct
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month;Frequently asked questions
What is the CASE statement in SQL?+
What is the difference between simple CASE and searched CASE?+
Can you use CASE in a WHERE clause?+
What happens if no CASE condition matches and there is no ELSE?+
You'll be fluent by next Tuesday
- 01
Active recall beats re-reading by 50%
Cognitive-science meta-reviews (Dunlosky et al., 2013) rank practice testing as a top-tier study technique, while re-reading and highlighting rank near the bottom
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
Five problem shapes cover 80% of data engineer loops
Dedup, sessionization, top-N-per-group, slowly-changing dimensions, partition tricks. Writing the shapes by hand turns the unfamiliar into pattern recognition
More reading
Full CASE WHEN reference with advanced patterns and performance considerations
Hands-on CASE statement problems with real SQL execution at interview difficulty
Complete guide to every SQL topic tested in data engineering interviews