Tags: web-dev analytics concept
Common Table Expressions
Date: 2026-09-27
A named, query-scoped intermediate result.
WITHturns one nested query read inside-out into a sequence of steps read top to bottom — which is the difference between an analytics query you can check and one you have to trust.
A common table expression (CTE) is a temporary named result set defined with WITH at the top of a query, which the rest of the query can reference like a table. It exists only for that one statement.
Nested vs named
The question: repeat-purchase rate by first-order channel.
Nested — read from the innermost bracket outwards:
SELECT first_channel, AVG(CASE WHEN n_orders > 1 THEN 1.0 ELSE 0 END) AS repeat_rate
FROM (
SELECT c.customer_id, c.first_channel, COUNT(o.order_id) AS n_orders
FROM (
SELECT customer_id, channel AS first_channel
FROM (
SELECT customer_id, channel,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at) AS rn
FROM orders
) numbered WHERE rn = 1
) c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.first_channel
) per_customer
GROUP BY first_channel;As CTEs — the same query, read in the order it happens:
WITH numbered AS ( -- 1. number each customer's orders
SELECT customer_id, channel,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at) AS rn
FROM orders
),
first_orders AS ( -- 2. one row per customer: where they came from
SELECT customer_id, channel AS first_channel
FROM numbered WHERE rn = 1
),
per_customer AS ( -- 3. one row per customer: how many orders
SELECT customer_id, COUNT(*) AS n_orders
FROM orders GROUP BY customer_id
)
SELECT f.first_channel,
AVG(CASE WHEN p.n_orders > 1 THEN 1.0 ELSE 0 END) AS repeat_rate
FROM first_orders f
JOIN per_customer p USING (customer_id) -- both one row per customer: no fan-out
GROUP BY f.first_channel;Notice each CTE has a stated grain — one row per order, then one per customer. Joining two things that are both one-row-per-customer can’t fan out, which is exactly the check Joins asks for. The nested version hides the grain inside the brackets.
Why it’s the default for analytics SQL
- Each step is checkable. Swap the final
SELECTforSELECT * FROM first_orders LIMIT 20and inspect the intermediate. Debugging becomes bisection - Names carry intent.
first_orderssays what a subquery aliasedcdoesn’t - Reuse within the query. One CTE can be referenced several times — two different aggregations off the same filtered base
- It’s the shape dbt models take — each model is effectively a CTE promoted to a table, and dbt style guides write models as a chain of CTEs internally [CHECK: current dbt style guide wording]
Recursive CTEs
WITH RECURSIVE lets a CTE reference itself — a base case, then a step repeated until it returns no rows. The job it does in web work: walking a hierarchy of unknown depth — category trees, referral chains, org charts.
WITH RECURSIVE tree AS (
SELECT id, parent_id, name, 1 AS depth
FROM categories WHERE id = 42 -- base: start at "Footwear"
UNION ALL
SELECT c.id, c.parent_id, c.name, t.depth + 1
FROM categories c
JOIN tree t ON c.parent_id = t.id -- step: children of anything already found
)
SELECT * FROM tree; -- stops when a step finds no new childreniteration rows added
base 42 Footwear
1 57 Trainers, 58 Boots
2 91 Running, 92 Walking (children of Trainers)
3 none → stop
A cycle in the data loops forever — Postgres has a CYCLE clause, and elsewhere a depth cap in the step (WHERE t.depth < 20) is the guard. The other common use is generating a series (every date in a range) to join against so days with zero orders appear as zero rather than vanishing — SQL Dialects has the per-engine shortcuts.
Performance: inlined or materialised
A CTE is a naming device, not a promise about execution. The planner may:
- Inline it — substitute it into the main query as if it were a subquery, so filters from outside can be pushed into it. Usually what you want
- Materialise it — compute it once into a temporary result and read from that. Better when it’s expensive and referenced several times; worse when an outside filter could have shrunk it
Postgres 12+ inlines a non-recursive CTE referenced once; you can force
either with MATERIALIZED / NOT MATERIALIZED
earlier always materialised — CTEs were an "optimisation fence"
Old advice that “CTEs are slow” dates from that fence. [CHECK: BigQuery’s behaviour — it generally does not cache a CTE referenced twice, so each reference can re-scan; verify before relying on it for cost.] When a CTE really needs computing once, a temporary table is the explicit version — Query Performance · Query Planning.
Where not to reach for one
- A single simple subquery —
WHERE id IN (SELECT ...)— doesn’t gain from a name - Something reused across many queries — that’s a view or a model, not a CTE copied between files