Conditional Aggregation with CASE WHEN
Progress Tracking
Log in to save this lesson and continue from where you left off.
When Basic Aggregates Aren’t Enough
So far, your aggregates operate on all rows (or all rows in a group). But what if you need to count only certain values? Sum only specific items? Get multiple conditional counts in one query?
You could run separate queries with different WHERE clauses. But there’s a better way: put CASE WHEN inside your aggregate function.
Quick CASE Refresher
Before we dive in, here’s the CASE syntax:
CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
ELSE default_result
ENDCASE evaluates conditions in order and returns the result for the first true condition. If none match, it returns the ELSE value (or NULL if there’s no ELSE).
The Pattern: SUM(CASE WHEN…)
Here’s the most common pattern. It counts or sums values that meet a condition:
SELECT
COUNT(*) AS total_employees,
SUM(CASE WHEN department = 'HR' THEN 1 ELSE 0 END) AS hr_count,
SUM(CASE WHEN department = 'Admin' THEN 1 ELSE 0 END) AS admin_count,
SUM(CASE WHEN department = 'Account' THEN 1 ELSE 0 END) AS account_count
FROM techcorp_workforce;One row back with four columns: total, HR count, Admin count, Account count. No GROUP BY needed.
How It Works
For each row, CASE WHEN checks the condition. If true, it returns 1. If false, it returns 0. SUM adds up all those 1s and 0s. The result: a count of matching rows.
You can also use COUNT(CASE WHEN condition THEN 1 END) without the ELSE. COUNT ignores NULLs, so it only counts the 1s. Same result, slightly different approach.
Watch Out for NULLs
If a column value is NULL, comparisons like salary < 80000 are neither true nor false. The CASE falls through to the ELSE.
-- If salary is NULL, this returns 0 (the ELSE)
SUM(
CASE
WHEN salary < 80000 THEN 1
ELSE 0
END
)This effectively treats unknown salaries as “not matching.” That’s usually fine, but be aware of it. If you need to explicitly handle NULLs, add a condition:
SUM(
CASE
WHEN salary IS NULL THEN NULL -- Exclude from count entirely
WHEN salary < 80000 THEN 1
ELSE 0
END
)SELECT
ROUND(
100.0 * SUM(CASE WHEN salary > 100000 THEN 1 ELSE 0 END)
/ COUNT(*),
2
) AS pct_high_earners
FROM techcorp_workforce;Use 100.0 (not 100) to avoid integer division. In SQL, 3 / 4 = 0, but 3.0 / 4 = 0.75.
| id_guest | id_host | id_listing | ts_contact_at | ts_reply_at | ts_accepted_at | ts_booking_at | ds_checkin | ds_checkout | n_guests | n_messages |
|---|---|---|---|---|---|---|---|---|---|---|
| 86b39b70-965b-479d-a0b0-719b195acea2 | 1dfb22ec-c20e-4bf9-b161-1607afa25c5a | d668de42-122a-45cd-b91f-91a70895f902 | 2014-04-18 09:32:23 | 2014-04-18 09:39:06 | 2014-12-31 | 2015-01-02 | 7 | 5 | ||
| 14f943bb-74e9-458b-be55-203dc7220688 | 3347390d-8670-4870-9dab-da30f3700141 | 14c47fb8-e831-4044-9674-9b3fd0499193 | 2014-10-06 06:55:45 | 2014-10-06 10:06:38 | 2014-10-06 10:06:38 | 2014-10-06 10:06:38 | 2014-11-03 | 2014-11-07 | 2 | 8 |
| 425aa1ed-82ab-4ecf-b62f-d61e1848706d | 02cafb86-5445-45cc-80f2-405291578356 | c5a4a913-a094-4a9d-82e2-0b2d4f9d9eeb | 2014-10-04 05:02:39 | 2014-10-04 23:10:01 | 2014-11-02 | 2014-11-09 | 2 | 2 | ||
| bb490ede-8a70-4d61-a2e8-625855a393e2 | f49c3095-58de-4b8d-9d5b-3bfceceb47d8 | 27f4b429-d544-464f-b4b5-3c09fd5992e7 | 2014-08-31 11:46:11 | 2014-08-31 16:48:28 | 2014-11-03 | 2014-11-07 | 2 | 5 | ||
| b2fda15a-89bb-4e6e-ae81-8b21598e2482 | 71f1d49e-2ff4-4d72-b8e6-fd4c67feaa74 | 95fb78ca-8e6e-436a-9830-949d995ad14f | 2014-10-08 15:07:56 | 2014-10-08 15:32:12 | 2014-10-08 15:32:12 | 2014-10-08 22:21:41 | 2014-11-06 | 2014-11-09 | 2 | 10 |
Calculate what percentage of Airbnb contact requests were accepted (ts_accepted_at IS NOT NULL).
Conditional Sums
You can sum values conditionally, not just count.
| id | first_name | last_name | department | salary | phone_number | joining_date |
|---|---|---|---|---|---|---|
| 1 | Sarah | Mitchell | HR | 95000 | 555-0101 | 2021-03-15 |
| 2 | Michael | Chen | HR | 88000 | 555-0102 | 2022-06-01 |
| 3 | Emily | Rodriguez | HR | 82500 | 2021-09-20 | |
| 4 | David | Park | HR | 80000 | 555-0104 | 2023-01-10 |
| 5 | Lisa | Thompson | HR | 65000 | 2021-04-05 |
Add an Admin payroll column alongside the existing HR payroll to break down the total by department.
Now you get payroll broken down by department, all in one row.
Creating Pivot Tables
Here’s where this gets really useful. You can create pivot-style cross-tabulations:
SELECT
survived,
SUM(CASE WHEN pclass = 1 THEN 1 ELSE 0 END) AS first_class,
SUM(CASE WHEN pclass = 2 THEN 1 ELSE 0 END) AS second_class,
SUM(CASE WHEN pclass = 3 THEN 1 ELSE 0 END) AS third_class
FROM titanic
GROUP BY survived;This creates a cross-tabulation: survival status on the rows, passenger class on the columns. One query, and you’ve got a pivot table.
Titanic Survivors and Non-Survivors
Use SUM(CASE WHEN pclass = X THEN 1 ELSE 0 END) for each class, grouped by survived status.
| passengerid | survived | pclass | name | sex | age | sibsp | parch | ticket | fare | cabin | embarked |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 0 | 3 | Braund, Mr. Owen Harris | male | 22 | 1 | 0 | A/5 21171 | 7.25 | S | |
| 2 | 1 | 1 | Cumings, Mrs. John Bradley (Florence Briggs Thayer) | female | 38 | 1 | 0 | PC 17599 | 71.28 | C85 | C |
| 3 | 1 | 3 | Heikkinen, Miss. Laina | female | 26 | 0 | 0 | STON/O2. 3101282 | 7.92 | S | |
| 4 | 1 | 1 | Futrelle, Mrs. Jacques Heath (Lily May Peel) | female | 35 | 1 | 0 | 113803 | 53.1 | C123 | S |
| 5 | 0 | 3 | Allen, Mr. William Henry | male | 35 | 0 | 0 | 373450 | 8.05 | S |
Make a report showing the number of survivors and non-survivors by passenger class. Classes are categorized based on the `pclass` value as: • First class: `pclass = 1` • Second class: `pclass = 2` • Third class: `pclass = 3` Output the number of survivors and non-survivors by each class.
Multiple Conditions
CASE WHEN can handle complex logic.
Add the mid-range (75000 to under 100000) and over-100K buckets to create a three-tier salary distribution.
Inspections by Risk Category
Use SUM(CASE WHEN risk_category = ... THEN 1 ELSE 0 END) for each category. Remember to handle the NULL risk category as a separate group.
| business_id | business_name | business_address | business_city | business_state | business_postal_code | business_latitude | business_longitude | business_location | business_phone_number | inspection_id | inspection_date | inspection_score | inspection_type | violation_id | violation_description | risk_category |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5800 | John Chin Elementary School | 350 Broadway St | San Francisco | CA | 94133 | 37.8 | -122.4 | {'longitude': '-122.403154', 'needs_recoding': False, 'latitude': '37.798358', 'human_address': '{"address":"","city":"","state":"","zip":""}'} | 5800_20171017 | 2017-10-17 | 98 | Routine - Unscheduled | 5800_20171017_103149 | Wiping cloths not clean or properly stored or inadequate sanitizer | Low Risk | |
| 64236 | Sutter Pub and Restaurant | 700 Sutter St | San Francisco | CA | 94102 | 37.79 | -122.41 | {'longitude': '-122.41188', 'needs_recoding': False, 'latitude': '37.78881', 'human_address': '{"address":"","city":"","state":"","zip":""}'} | 64236_20170725 | 2017-07-25 | 88 | Routine - Unscheduled | 64236_20170725_103133 | Foods not protected from contamination | Moderate Risk | |
| 1991 | SRI THAI CUISINE | 4621 LINCOLN Way | San Francisco | CA | 94122 | 37.76 | -122.51 | {'longitude': '-122.507779', 'needs_recoding': False, 'latitude': '37.764073', 'human_address': '{"address":"","city":"","state":"","zip":""}'} | 1991_20171129 | 2017-11-29 | 86 | Routine - Unscheduled | 1991_20171129_103139 | Improper food storage | Low Risk | |
| 3816 | Washington Bakery & Restaurant | 733 Washington St | San Francisco | CA | 94108 | 37.8 | -122.41 | {'longitude': '-122.405845', 'needs_recoding': False, 'latitude': '37.795174', 'human_address': '{"address":"","city":"","state":"","zip":""}'} | 3816_20160728 | 2016-07-28 | 67 | Routine - Unscheduled | 3816_20160728_103108 | Contaminated or adulterated food | High Risk | |
| 39119 | Brothers Restaurant | 4128 GEARY Blvd | San Francisco | CA | 94118 | 37.78 | -122.46 | {'longitude': '-122.463762', 'needs_recoding': False, 'latitude': '37.781148', 'human_address': '{"address":"","city":"","state":"","zip":""}'} | 39119_20160718 | 2016-07-18 | 79 | Routine - Unscheduled | 39119_20160718_103133 | Foods not protected from contamination | Moderate Risk |
Find the number of inspections that resulted in each risk category per each inspection type. Consider the records with no risk category value belongs to a separate category. Output the result along with the corresponding inspection type and the corresponding total number of inspections per that type. The output should be pivoted, meaning that each risk category + total number should be a separate column. Order the result based on the number of inspections per inspection type in descending order.
Share of Active Users
Calculate a percentage using SUM(CASE WHEN) divided by COUNT(*). Use 100.0 * to avoid integer division.
| user_id | name | status | country |
|---|---|---|---|
| 33 | Amanda Leon | open | Australia |
| 27 | Jessica Farrell | open | Luxembourg |
| 18 | Wanda Ramirez | open | USA |
| 50 | Samuel Miller | closed | Brazil |
| 16 | Jacob York | open | Australia |
Calculate the percentage of users who are both from the US and have an 'open' status, as indicated in the `fb_active_users` table.
Key Takeaways
SUM(CASE WHEN condition THEN 1 ELSE 0 END)counts matching rows- This lets you get multiple conditional counts without
GROUP BY NULLvalues fall through toELSE(treated as non-matching)- Use it for percentages: conditional count divided by total count
- Use 100.0 for percentages to avoid integer division
- You can sum values conditionally, not just count
- Combined with
GROUP BY, you can create pivot-style reports
What’s Next
You’ve now got all the building blocks. The final lesson pulls everything together with complex queries that combine filtering, grouping, and conditional aggregation.