CTEs and Query Design
Writing SQL other analysts can audit
Common Table Expressions (WITH clauses) turn monster SQL into readable, testable stages. Good query design is the maintainability habit that keeps report queries trustworthy.
What you will learn
- Structure analytical queries as named, single-purpose CTEs
- Debug a complex query stage by stage
- Decide when a CTE should become a real model/view
What is this?
CTEs (WITH ... AS) name a subquery so the rest of the query reads like a pipeline: raw → cleaned → aggregated → formatted. They do not change performance semantics much in most engines (they may be inlined), but they transform an un-auditable 200-line query into reviewable stages.
Why do you need it?
- Long queries are bug-magnets; CTE stages localize where a value went wrong.
- Named stages make the intent visible ('clean_orders', 'region_totals') to reviewers and future-you.
- CTEs enable unit-testing habits: run one stage and assert its output.
- A well-designed query is the first step toward promoting it into a modeled view.
Mental model
A chef's mise en place
CTE query stages
Ingredients prepped, labeled and lined up before cooking. Each CTE is a prep station — you can verify each bowl before it goes into the pan, and nobody has to read one giant recipe wall of text.
How it works
- WITH defines reusable named subqueries before the main SELECT.
- Each CTE consumes earlier CTEs, forming a readable pipeline.
- Recursive CTEs support calendars, hierarchies and path-finding.
- Engines typically inline CTEs (materialization is a performance choice to verify).
- CLLUTION: run any CTE in isolation to test it before the main query.
A staged pipeline inside one query.
Examples
A readable three-stage query
WITH valid_orders AS ( SELECT * FROM orders WHERE status = 'confirmed'),monthly AS ( SELECT date_trunc('month', ts) AS month, SUM(amount) AS revenue FROM valid_orders GROUP BY 1)SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prevFROM monthly ORDER BY month;Stage one cleans, stage two aggregates, stage three reads for the report. Each stage is separately reviewable and testable.
In production
- A retention query builds events → cohorts → matrix as three CTEs, and each is verified in CI.
- A finance reconciliation query uses CTEs for journal, ledger and diff — each tested against fixtures.
- A recursive CTE generates a calendar to backfill empty report periods.
Best practices
- One idea per CTE; name it by what it produces, not by the tool.
- Keep stages filter-early so later stages work on smaller data.
- Test boundary stages independently (run just the CTE).
- Promote stable, reused CTE logic into real views/models with tests and lineage.
Common mistakes
- One giant SELECT with subqueries nested to oblivion.
- Recomputing the same aggregation in every branch (write it once as a CTE).
- Naming CTEs vaguely ('x', 'temp') so review is impossible.
- Assuming CTEs are always materialized — check the plan before relying on it.
Common issues
Slow query despite clean CTEsInlining recomputed a stage repeatedly in some engines
Check EXPLAIN; materialize the hot CTE or build it as a real table.
Best-practice CTE refactor changed resultsSemantics changed (a filter moved stages)
Diff results before/after refactor the same way you'd diff test suites.
Debugging tips
- Run each CTE separately and inspect output before assembling.
- Put row-count assertions at stage boundaries for early failure.
- Isolate a wrong value by bisecting CTEs in half the time.
Interview checkpoint
Quick quiz
The main benefit of structuring a query with CTEs is:
CTEs always prevent engines from re-computing an expensive stage.