CTE Patterns for Common Problems
Progress Tracking
Log in to save this lesson and continue from where you left off.
Ratios and Percentages
Calculating ratios is one of the most common analytical tasks. It’s also where queries silently break if you’re not careful.
The NULLIF Pattern
Division by zero crashes your query. NULLIF prevents this:
-- Without protection: crashes if denominator is 0
SELECT numerator / denominator AS ratio;
-- With NULLIF: returns NULL instead of crashing
SELECT numerator / NULLIF(denominator, 0) AS ratio;NULLIF(x, 0) returns NULL when x equals 0, and returns x otherwise. NULL divided by anything is NULL, so your query continues instead of failing.
The 100.0 (not 100) forces decimal division. Without the decimal, you get integer division and lose precision. NULLIF prevents divide-by-zero. Get in the habit of using both for percentages.
The Full Pattern
| id | first_name | last_name | age | sex | employee_title | department | salary | target | bonus | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Max | George | 26 | M | Sales | Sales | 1300 | 200 | 150 | Max@company.com | California | 2638 Richards Avenue | 1 |
| 13 | Katty | Bond | 56 | F | Manager | Management | 150000 | 0 | 300 | Katty@company.com | Arizona | 1 | |
| 11 | Richerd | Gear | 57 | M | Manager | Management | 250000 | 0 | 300 | Richerd@company.com | Alabama | 1 | |
| 10 | Jennifer | Dion | 34 | F | Sales | Sales | 1000 | 200 | 150 | Jennifer@company.com | Alabama | 13 | |
| 19 | George | Joe | 50 | M | Manager | Management | 100000 | 0 | 300 | George@company.com | Florida | 1003 Wyatt Street | 1 |
Calculate what percentage of the company each department represents by headcount. Use NULLIF for safety and 100.0 for decimal division.
Inspections by Risk Category
An inspection can have several violations in different risk categories. Use a CTE to first collapse the data to one row per inspection, keeping only its highest-severity category, then pivot with SUM(CASE WHEN ...) in the final SELECT. Treat inspections with no risk category as their own group, and order by the total inspection count.
| 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 |
A city health department wants to see how inspection outcomes break down by risk level for each type of inspection it runs. Each inspection can have multiple violation records, and each record carries its own risk category (or none). For each inspection, take the highest-severity risk category among its records — ranked High Risk > Moderate Risk > Low Risk > no risk category — and count that inspection once, under its inspection type, in the corresponding risk category. If none of an inspection's records have a risk category, count that inspection under its own no-risk-category bucket. Output the inspection type, the count of inspections in each risk category (no risk category, low risk, moderate risk, high risk) as separate columns, and the total number of inspections for that inspection type, sorted by the total number of inspections per inspection type in descending order.
Rates and percentages always need NULLIF(denominator, 0). Make it a habit.
The Pre-Aggregate-and-Join Pattern
Remember correlated subqueries from Lesson 4.1? They run once per row, which can be slow. Here’s the faster alternative.
The Problem: Correlated Subquery
-- Slow: runs the AVG calculation for every single row
SELECT
e1.first_name,
e1.salary,
e1.department
FROM employees e1
WHERE e1.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department = e1.department
);For a table with 10,000 employees across 50 departments, that’s 10,000 separate AVG calculations.
The Solution: Pre-Aggregate with CTE
| id | first_name | last_name | age | sex | employee_title | department | salary | target | bonus | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Max | George | 26 | M | Sales | Sales | 1300 | 200 | 150 | Max@company.com | California | 2638 Richards Avenue | 1 |
| 13 | Katty | Bond | 56 | F | Manager | Management | 150000 | 0 | 300 | Katty@company.com | Arizona | 1 | |
| 11 | Richerd | Gear | 57 | M | Manager | Management | 250000 | 0 | 300 | Richerd@company.com | Alabama | 1 | |
| 10 | Jennifer | Dion | 34 | F | Sales | Sales | 1000 | 200 | 150 | Jennifer@company.com | Alabama | 13 | |
| 19 | George | Joe | 50 | M | Manager | Management | 100000 | 0 | 300 | George@company.com | Florida | 1003 Wyatt Street | 1 |
Use a CTE to calculate the average salary per department, then JOIN it back to show each employee alongside their department average. Filter to above-average earners.
Now we calculate 50 averages (one per department), not 10,000. Same result, dramatically faster.
Highest Target Under Manager
Use a CTE to pre-aggregate, then filter based on the aggregated values.
| id | first_name | last_name | age | sex | employee_title | department | salary | target | bonus | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Allen | Wang | 55 | F | Manager | Management | 200000 | 600 | 300 | Allen@company.com | California | 1069 Ventura Drive | 1 |
| 20 | Sarrah | Bicky | 31 | F | Senior Sales | Sales | 2000 | 500 | 150 | Sarrah@company.com | Florida | 1176 Tyler Avenue | 19 |
| 7 | Nicky | Bat | 33 | F | Sales | Sales | 1400 | 400 | 100 | Molly@company.com | Arizona | 3461 Preston Street | 13 |
| 31 | Steve | Smith | 39 | M | Sales | Sales | 1500 | 400 | 125 | steve@company.com | Texas | 13 | |
| 32 | David | Warner | 38 | M | Sales | Sales | 1200 | 400 | 150 | david@company.com | Florida | 13 |
Identify the employee(s) working under manager `manager_id=13` who have achieved the highest target. Return each such employee’s first name alongside the target value. The goal is to display the maximum target among all employees under `manager_id=13` and show which employee(s) reached that top value.
Replacing Self-Referencing Queries
When you need to compare rows within the same table (like employees to their managers), the CTE approach is cleaner.
The Correlated Way
-- Find employees earning more than their manager
SELECT
e.first_name,
e.salary
FROM employee e
WHERE e.salary > (
SELECT m.salary
FROM employee m
WHERE m.id = e.manager_id
);The CTE Way (Clearer and Often Faster)
WITH manager_salaries AS (
SELECT
id,
salary AS manager_salary
FROM employee
WHERE id IN (
SELECT DISTINCT manager_id
FROM employee
)
)
SELECT
e.first_name,
e.salary
FROM employee e
JOIN manager_salaries m
ON e.manager_id = m.id
WHERE e.salary > m.manager_salary;Or even simpler with a self-join (from Module 3):
SELECT
e.first_name,
e.salary
FROM employee e
JOIN employee m
ON e.manager_id = m.id
WHERE e.salary > m.salary;Employee and Manager Salaries
Compare employees to their managers using the approach you find clearest.
| id | first_name | last_name | age | sex | employee_title | department | salary | target | bonus | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Max | George | 26 | M | Sales | Sales | 1300 | 200 | 150 | Max@company.com | California | 2638 Richards Avenue | 1 |
| 13 | Katty | Bond | 56 | F | Manager | Management | 150000 | 0 | 300 | Katty@company.com | Arizona | 1 | |
| 11 | Richerd | Gear | 57 | M | Manager | Management | 250000 | 0 | 300 | Richerd@company.com | Alabama | 1 | |
| 10 | Jennifer | Dion | 34 | F | Sales | Sales | 1000 | 200 | 150 | Jennifer@company.com | Alabama | 13 | |
| 19 | George | Joe | 50 | M | Manager | Management | 100000 | 0 | 300 | George@company.com | Florida | 1003 Wyatt Street | 1 |
Find employees who are earning more than their managers. Output the employee's first name along with the corresponding salary.
When Subqueries Still Make Sense
CTEs are preferred, but simple subqueries have their place:
- Simple scalar lookups:
WHERE x = (SELECT MAX(x)...)is perfectly readable - Quick
EXISTSchecks: sometimes clearer than aCTEplusJOIN - One-off filters: when a
CTEwould be overkill - Reading legacy code: you’ll encounter subqueries constantly
The goal isn’t to eliminate subqueries. It’s about choosing the right tool for each situation. When queries get complex, CTEs are almost always the better choice.
Dialect Notes
CTEs work across all major SQL dialects with minor variations:
- PostgreSQL, MySQL 8.0+, SQL Server, Oracle: all support
CTEswithWITH - Recursive
CTEsuseWITH RECURSIVEin PostgreSQL/MySQL, justWITHin SQL Server/Oracle - Recursive
CTEsare advanced content covered in Premium Learning Paths
| id | first_name | last_name | age | sex | employee_title | department | salary | target | bonus | city | address | manager_id | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Max | George | 26 | M | Sales | Sales | 1300 | 200 | 150 | Max@company.com | California | 2638 Richards Avenue | 1 |
| 13 | Katty | Bond | 56 | F | Manager | Management | 150000 | 0 | 300 | Katty@company.com | Arizona | 1 | |
| 11 | Richerd | Gear | 57 | M | Manager | Management | 250000 | 0 | 300 | Richerd@company.com | Alabama | 1 | |
| 10 | Jennifer | Dion | 34 | F | Sales | Sales | 1000 | 200 | 150 | Jennifer@company.com | Alabama | 13 | |
| 19 | George | Joe | 50 | M | Manager | Management | 100000 | 0 | 300 | George@company.com | Florida | 1003 Wyatt Street | 1 |
Calculate each department salary as a percentage of the total. Show only departments over 20%.
Key Takeaways
NULLIF(x, 0)prevents divide-by-zero errors; use it for all ratios- Use
100.0(not100) to force decimal division - Pre-aggregate-and-join beats correlated subqueries for performance
- Calculate once in a
CTEinstead of once per row - Simple subqueries still have their place; choose the clearest approach
- Debug by running each
CTEindependently
What’s Next
Congratulations! You’ve completed Module 4: Subqueries and CTEs. You now have powerful tools for breaking complex problems into manageable steps, comparing rows to aggregates, and writing queries that are actually readable six months later.
In the next module, you’ll tackle Window Functions. They’re asked in many data analyst interviews and let you do things that would be incredibly complex with CTEs alone: running totals, rankings, and period-over-period comparisons without self-joins.