SQL CTE (WITH Clause): Chaining, CTE vs Subquery, and Recursive CTEs

Learn PostgreSQL CTE scope, grain-first chaining, derived-table equivalence, cycle-safe recursion, and dense date spines with verified outputs.

Author: PracHub

Published: 8/14/2026

SQL CTE (WITH Clause): Chaining, CTE vs Subquery, and Recursive CTEs

August 14, 2026
26 min read
SQL CTE (WITH Clause): Chaining, CTE vs Subquery, and Recursive CTEs

Quick Overview

A Data Engineer guide to common table expressions in PostgreSQL. Five executed walkthroughs cover statement scope, multi-stage grain changes, CTE versus derived-table structure, cycle-safe dependency traversal, and recursive date-spine generation.

Data EngineerFree

A common table expression gives one transformation a name inside one SQL statement. That name is useful when a data pipeline has several grains: detail rows, deduplicated entities, grouped metrics, then a final result.

The name is not stored and does not survive the statement. Treat each CTE as an interface: state what one row represents, select only the columns the next step needs, and verify where row counts change.

Name one transformation with a CTE

The input is one row per order. completed_orders keeps only completed detail rows; the outer query changes the grain to one row per customer.

Input: orders

order_idcustomer_idorder_dateamountstatus
11012026-01-03120.00completed
21012026-01-1145.50completed
31022026-01-05300.00completed
41022026-01-1980.00cancelled
51032026-01-0715.00completed
61032026-02-02210.00completed
71042026-02-0460.00refunded
WITH completed_orders AS (
  SELECT customer_id, amount
  FROM orders
  WHERE status = 'completed'
)
SELECT
  customer_id,
  COUNT(*) AS completed_orders,
  SUM(amount) AS completed_revenue
FROM completed_orders
GROUP BY customer_id
ORDER BY customer_id;
Row flow through one filtering CTE Seven order rows enter the query, the completed-orders CTE keeps five rows, and grouping produces three customer rows. 7 order rowsdetail grain5 completed rowsnamed CTE result3 customer rowsgrouped output
The CTE names the filter; GROUP BY still performs the grain change.

Output

customer_idcompleted_orderscompleted_revenue
1012165.50
1021300.00
1032225.00

Only the statement following WITH can reference completed_orders. If another statement needs the same result, repeat the definition, create a persistent object under an explicit lifecycle, or move the shared logic into the data model. The GROUP BY guide covers the aggregate grain used here.

Chain CTEs by declaring each grain

Later CTEs can reference earlier ones. This pipeline moves from completed order rows, to one row per customer, to ranked customer summaries. The final filter keeps two rows.

Input: orders

order_idcustomer_idorder_dateamountstatus
11012026-01-03120.00completed
21012026-01-1145.50completed
31022026-01-05300.00completed
41022026-01-1980.00cancelled
51032026-01-0715.00completed
61032026-02-02210.00completed
71042026-02-0460.00refunded
WITH completed_orders AS (
  SELECT customer_id, amount
  FROM orders
  WHERE status = 'completed'
),
per_customer AS (
  SELECT
    customer_id,
    SUM(amount) AS completed_revenue
  FROM completed_orders
  GROUP BY customer_id
),
ranked_customers AS (
  SELECT
    customer_id,
    completed_revenue,
    ROW_NUMBER() OVER (
      ORDER BY completed_revenue DESC, customer_id
    ) AS revenue_rank
  FROM per_customer
)
SELECT
  customer_id,
  completed_revenue,
  revenue_rank
FROM ranked_customers
WHERE revenue_rank <= 2
ORDER BY revenue_rank;
Row flow through a three-CTE pipeline Seven orders filter to five completed rows, aggregate to three customer rows, and rank down to two final rows. 7 orders5 completed3 customer summariesrank at customer grain2 output rowsranks 1 and 2
Names make the grain transitions reviewable, but the query is correct only because each transition matches the next operation.

Output

customer_idcompleted_revenuerevenue_rank
102300.001
103225.002

The final WHERE can see revenue_rank because the window calculation happened in the previous query level. SQL order of operations explains why the wrapper is required.

CTE and derived-table forms can return the same rows

A CTE is not automatically a different algorithm. When a transformation is referenced once, a derived table can express the same logical result. This version returns the same three customer summaries as the first query.

Input: orders

order_idcustomer_idorder_dateamountstatus
11012026-01-03120.00completed
21012026-01-1145.50completed
31022026-01-05300.00completed
41022026-01-1980.00cancelled
51032026-01-0715.00completed
61032026-02-02210.00completed
71042026-02-0460.00refunded
SELECT
  customer_id,
  COUNT(*) AS completed_orders,
  SUM(amount) AS completed_revenue
FROM (
  SELECT customer_id, amount
  FROM orders
  WHERE status = 'completed'
) AS completed_orders
GROUP BY customer_id
ORDER BY customer_id;
Row flow through an equivalent derived table Seven order rows pass through an inline derived table that keeps five completed rows, then aggregate into the same three customer rows. 7 order rowsdetail grain5 inline rowsderived table3 customer rowssame exact output
Choose the form that makes the transformation boundaries clear, then inspect the actual plan when performance matters.

Output

customer_idcompleted_orderscompleted_revenue
1012165.50
1021300.00
1032225.00

A CTE is easier to reuse by name later in the same statement. A derived table stays beside the single clause that consumes it. A temporary table has a longer lifecycle and can be indexed, but it also introduces separate statements and cleanup policy. Those are design choices, not universal speed rankings.

Recursive CTEs walk dependencies with a stop rule

Recursion has an anchor query and a recursive member joined with UNION ALL. The path array below is both evidence and a cycle guard: once a job name is in the current path, that route cannot visit it again.

Input: job_dependencies

parent_jobchild_job
ingestclean
cleanfeatures
featurestrain
trainpublish
cleanaudit
auditingest
WITH RECURSIVE dependency_walk AS (
  SELECT
    parent_job AS root_job,
    child_job AS job,
    ARRAY[parent_job, child_job] AS path,
    1 AS depth
  FROM job_dependencies
  WHERE parent_job = 'ingest'

  UNION ALL

  SELECT
    w.root_job,
    d.child_job,
    w.path || d.child_job,
    w.depth + 1
  FROM dependency_walk AS w
  JOIN job_dependencies AS d
    ON d.parent_job = w.job
  WHERE NOT d.child_job = ANY(w.path)
    AND w.depth < 10
)
SELECT
  job,
  depth,
  array_to_string(path, ' -> ') AS path
FROM dependency_walk
ORDER BY depth, job;
Row flow through a recursive dependency walk One anchor edge starts at ingest, recursive joins discover four additional job rows, and the path guard rejects the edge from audit back to ingest for five output rows. 1 anchor rowingest to cleanExpand unvisited jobsblock audit to ingest cycle5 reachable rowsdepths 1 through 4
The depth limit is a second guardrail; the path check is what prevents revisiting a node on the current route.

Output

jobdepthpath
clean1ingest -> clean
audit2ingest -> clean -> audit
features2ingest -> clean -> features
train3ingest -> clean -> features -> train
publish4ingest -> clean -> features -> train -> publish

This query returns paths, not a globally deduplicated node list. In a graph where a job is reachable through two parents, it can appear twice with different paths. Decide whether the result grain is node, edge, or path before adding DISTINCT. Consecutive-run grouping is a different problem covered in SQL gaps and islands.

Recursive CTEs can build a dense date spine

An event table has no row for a quiet day. The recursive spine emits every report date, the aggregate counts existing events, and the left join turns missing dates into explicit zeroes.

Input: report_window

start_dateend_date
2026-03-012026-03-05

Input: daily_events

event_idevent_date
12026-03-01
22026-03-01
32026-03-03
42026-03-05
WITH RECURSIVE date_spine AS (
  SELECT start_date AS report_date
  FROM report_window

  UNION ALL

  SELECT report_date + 1
  FROM date_spine
  CROSS JOIN report_window
  WHERE report_date < end_date
),
event_counts AS (
  SELECT
    event_date,
    COUNT(*) AS event_count
  FROM daily_events
  GROUP BY event_date
)
SELECT
  s.report_date,
  COALESCE(e.event_count, 0) AS event_count
FROM date_spine AS s
LEFT JOIN event_counts AS e
  ON e.event_date = s.report_date
ORDER BY s.report_date;
Row flow from sparse events to a dense date spine Four event rows aggregate onto three dates, a recursive CTE creates five report dates, and a left join produces five output rows including two zero days. 4 events on 3 datessparse input rows5 generated datesjoin counts by date5 output rows2 explicit zero days
The anchor emits the first date; the recursive member adds one day until the inclusive end date is reached.

Output

report_dateevent_count
2026-03-012
2026-03-020
2026-03-031
2026-03-040
2026-03-051

PostgreSQL also provides generate_series for this shape. Recursion remains useful when each next row depends on more than a fixed interval. The SQL date-functions guide develops boundary and calendar choices.

FAQ

What is the scope of a CTE?

One statement. A CTE name can be referenced by the main query and by later CTEs in the same WITH clause. It is not visible to the next statement.

Can one CTE reference another?

Yes, when the referenced CTE appears earlier in the list. Recursive self-reference requires WITH RECURSIVE and belongs in the recursive member.

Is a CTE faster than a subquery?

There is no reliable form-only answer. They can describe the same logical operation, and planning depends on the database, version, query, statistics, and explicit materialization choices. Compare plans for the workload that matters.

When should I use a temporary table instead?

Consider one when several statements need the intermediate result, when an index on that result is useful, or when its lifecycle should be controlled independently. Account for creation, cleanup, transaction, and concurrency behavior.

How does a recursive CTE stop?

The recursive member eventually returns no new rows. Encode a business boundary, a cycle check, a depth limit, or a suitable combination. A query over an untrusted graph should not rely on the data being acyclic.


Comments (0)