CTE Fundamentals
Progress Tracking
Log in to save this lesson and continue from where you left off.
Finally, Readable Complex Queries
Nested subqueries are a nightmare to read. You start at the innermost level, work your way out, try to hold the whole thing in your head. There’s a better way.
CTEs (Common Table Expressions) let you name each step and lay them out top-to-bottom. They’re like creating temporary variables that hold query results. Once you start using them, you’ll wonder how you ever lived without them.
Your First CTE
WITH big_orders AS (
SELECT *
FROM orders
WHERE total_order_cost > 200
)
SELECT * FROM big_orders;| 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 |
Write a CTE that finds employees earning over 100000, then select from it.
You define high_earners first, then use it like a table in the main query. Compare this to the equivalent nested subquery and tell me which you’d rather debug at 11pm.
Basic Syntax
WITH cte_name AS (
SELECT ...
FROM ...
WHERE ...
)
SELECT *
FROM cte_name;The CTE must come before the main query. You can’t define it after SELECT.
The CTE Pattern for Finding Max
Remember the subquery pattern for finding the maximum? Here’s how CTEs make it cleaner:
-- Subquery approach (works, but nested)
SELECT
company,
profits
FROM companies
WHERE profits = (
SELECT MAX(profits)
FROM companies
);
-- CTE approach (clearer flow)
WITH max_profit AS (
SELECT MAX(profits) AS highest
FROM companies
)
SELECT
c.company,
c.profits
FROM companies c
JOIN max_profit m
ON c.profits = m.highest;The CTE approach separates “find the max” from “find who has it.” Each step is clear and testable.
Most Profitable Financial Company
Use a CTE to find the maximum profit, then join back to find the company.
| company | sector | industry | continent | country | marketvalue | sales | profits | assets | rank |
|---|---|---|---|---|---|---|---|---|---|
| ICBC | Financials | Major Banks | Asia | China | 215.6 | 148.7 | 42.7 | 3124.9 | 1 |
| China Construction Bank | Financials | Regional Banks | Asia | China | 174.4 | 121.3 | 34.2 | 2449.5 | 4 |
| Agricultural Bank of China | Financials | Regional Banks | Asia | China | 141.1 | 136.4 | 27 | 2405.4 | 8 |
| JPMorgan Chase | Financials | Major Banks | North America | United States | 229.7 | 105.7 | 17.3 | 2435.3 | 20 |
| Berkshire Hathaway | Financials | Investment Services | North America | United States | 309.1 | 178.8 | 19.5 | 493.4 | 17 |
Find the most profitable company from the financial sector. Output the result along with the continent.
Why CTEs Beat Nested Subqueries
Here’s a real example. You want each department’s salary as a percentage of company total.
The Nested Way (Hard to Follow)
SELECT
d.department,
d.total_salary,
d.total_salary * 100.0 / NULLIF(c.company_total, 0) AS pct
FROM (
SELECT
department,
SUM(salary) AS total_salary
FROM employees
GROUP BY department
) AS d
CROSS JOIN (
SELECT SUM(salary) AS company_total
FROM employees
) AS c;The CTE Way (Clear and Logical)
| 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 two CTEs to calculate each department salary as a percentage of the company total.
Same result, but you can read it top-to-bottom: first we calculate department totals, then company total, then combine them.
Average Salaries
Use a CTE to calculate department averages, then JOIN back to show each employee with their department’s average.
| 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 |
Compare each employee's salary with the average salary of the corresponding department. Output the department, first name, and salary of employees along with the average salary of that department.
CTE Scope
CTEs exist only within their statement. Once the query finishes, they’re gone.
If you need something persistent, you’d create a temporary table or a view. But for most analysis, CTEs are exactly what you need.
Naming Conventions
Name your CTEs well. Good names are documentation.
customer_orderstells you what’s in ittemp1tells you nothingdept_avgis clear and concisexis lazy and confusing
Future you (and your teammates) will thank you for descriptive CTE names.
Pre-Aggregating Before Joining
One of the most common patterns: aggregate data in a CTE, then join it back to add context.
WITH dept_stats AS (
SELECT
department,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department
)
SELECT
e.first_name,
e.salary,
d.avg_salary
FROM employees e
JOIN dept_stats d
ON e.department = d.department;Now every employee row shows alongside their department’s average. Want employees above average? Add WHERE e.salary > d.avg_salary.
Highest Salary In Department
Use a CTE to find department-level maximums, then filter or join to find the top earners.
| 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 the employee with the highest salary per department. Output the department name, employee's first name along with the corresponding salary.
Write a CTE that totals salary per department, then show only departments where the total exceeds 150000.
Key Takeaways
CTEs useWITHto create named temporary result sets- They make complex queries readable by laying out steps top-to-bottom
- The
CTEmust come before the mainSELECT CTEs exist only within their statement- Name
CTEs descriptively; they’re documentation - The pre-aggregate-and-join pattern is incredibly common
What’s Next
A single CTE is powerful. Multiple CTEs are transformative. Next, you’ll learn to chain CTEs that build on each other, reference the same CTE multiple times, and break complex analysis into logical steps.