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.
Processed Ticket Rate By Type
Use CTEs to calculate the numerator and denominator separately, then combine safely.
| complaint_id | type | processed |
|---|---|---|
| 0 | 0 | TRUE |
| 1 | 0 | TRUE |
| 2 | 0 | FALSE |
| 3 | 1 | TRUE |
| 4 | 1 | TRUE |
Find the processed rate of tickets for each `type`. The processed rate is defined as the number of processed tickets divided by the total number of tickets for that type. Round this result to two decimal places.
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 that will be 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.