Skip to main content

CTEs: Readable Multi-Step Queries

Level 2: Level 2: Aggregation & Joins (Combining Source Data)medium25 minWITHsingle and chained CTEsrefactoring nested subqueries

Name subqueries with WITH so a transform reads top-to-bottom.

Why nested subqueries get unreadable

A nested subquery forces you to read inside-out: find the innermost SELECT, understand it, then peel outward one layer at a time. Two levels deep and code review slows to a crawl. A Common Table Expression (CTE), written with WITH, names each step so the query reads top-to-bottom like a pipeline. This is not cosmetic. Production SQL, including every dbt model, is built as a chain of named CTEs precisely because named stages are reviewable, testable, and self-documenting.

A CTE is a named subquery, scoped to one statement

WITH name AS ( ... ) defines a temporary named result you can then query like a table. The demo below filters orders down to paid rows in a CTE called paid_orders, then aggregates revenue per customer from that CTE:

WITH paid_orders AS (
  SELECT customer_id, total_cents
  FROM orders
  WHERE status = 'paid'
)
SELECT customer_id, SUM(total_cents) AS revenue
FROM paid_orders
GROUP BY customer_id;
-- customer_id | revenue
-- 1           | 7000
-- 2           | 1000
-- 3           | 6000

Customer 2's cancelled order for 9000 never reaches the aggregate, because the WHERE status = 'paid' filter lives inside the CTE. Without an ORDER BY the row order is not guaranteed, so add one when order matters.

Chaining stages: aggregate first, filter second

Each CTE can reference the ones defined above it, giving the staging, intermediate, mart shape:

WITH paid_orders AS (
  SELECT customer_id, total_cents FROM orders WHERE status = 'paid'
),
per_customer AS (
  SELECT customer_id, SUM(total_cents) AS revenue
  FROM paid_orders
  GROUP BY customer_id
)
SELECT customer_id, revenue
FROM per_customer
WHERE revenue > 5000
ORDER BY customer_id;

The payoff is that final WHERE revenue > 5000. You cannot filter on an aggregate in the same SELECT that computes it: WHERE SUM(total_cents) > 5000 fails with misuse of aggregate, because WHERE is evaluated before rows are grouped. The one-statement fix is HAVING, but splitting the aggregate into its own CTE lets the next stage treat revenue as an ordinary column and filter it with a plain WHERE. As stages pile up, that reads far better, and it is exactly the structure the Apply and Practice ask for.

Pitfalls

  • Commas separate CTE definitions, but there is no comma before the final SELECT: ... ), per_customer AS ( ... ) SELECT ....
  • A CTE is scoped to the single statement that begins with its WITH. A later, separate query cannot see it.
  • Put each filter at the right stage. Row filters like status = 'paid' belong in the first CTE; aggregate filters (revenue, order_count) belong in a stage that reads the already-aggregated CTE.

Interview nuance: a CTE does not make a query faster on its own. Logically it is just a named subquery. Postgres 12 and later inline a non-recursive CTE that is referenced once, so it plans identically to the equivalent subquery, while Postgres 11 and earlier always materialized every CTE, which could actually be slower. Reach for CTEs to make a transform readable and reusable, not to optimize it.

Sample data for this example
CREATE TABLE orders (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER,
  status      TEXT,
  total_cents INTEGER
);
INSERT INTO orders VALUES
  (1, 1, 'paid',      3000),
  (2, 1, 'paid',      4000),
  (3, 2, 'paid',      1000),
  (4, 2, 'cancelled', 9000),
  (5, 3, 'paid',      6000);
Worked example (SQL)
WITH paid_orders AS (
  SELECT customer_id, total_cents
  FROM orders
  WHERE status = 'paid'
)
SELECT customer_id, SUM(total_cents) AS revenue
FROM paid_orders
GROUP BY customer_id;

Apply

Your turn

The task this lesson builds to.

Write a query that returns customer_id and revenue (a customer's total paid revenue), keeping only customers whose revenue is over 5000, sorted by customer_id ascending. Build it as two chained CTEs: from orders, first select the paid orders, then aggregate revenue per customer, then filter.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns one row per customer with customer_id, order_count, and revenue, keeping only customers who have more than 1 paid order and more than 10000 in total revenue, sorted by revenue descending. Revenue is the sum of each paid order line's quantity * unit_price_cents, and order_count is the count of distinct paid orders. Build it as three CTE stages like a production pipeline: paid_orders (paid orders joined to their line items, each line's revenue), per_customer (revenue and distinct order count per customer), then a final SELECT that applies the filter and sort.

4 hints and 1 automated check are waiting in the workspace.