Course topics

By WebNest Studio Engineering Team

PostgreSQL Tutorial

Common Table Expressions (CTEs) in PostgreSQL

A Common Table Expression, written with a WITH clause, lets you name a subquery and reference it as if it were a temporary table for the rest of the statement. CTEs exist primarily to make complex SQL readable: instead of nesting subqueries three levels deep, you can break a query into named, sequential steps that read almost like a small program.

Beyond readability, PostgreSQL also supports recursive CTEs, which can express queries that would otherwise be impossible in plain SQL — such as walking a tree or graph structure of arbitrary depth.

Why it matters

A query with subqueries nested four levels deep is correct and unreadable. A common table expression names each step and lets the query read from top to bottom. The recursive form does something plain SQL otherwise cannot: walk a hierarchy of unknown depth, such as a reporting chain or a category tree.

Basic WITH Clause Syntax

WITH cte_name AS (SELECT ...) SELECT ... FROM cte_name; You can define multiple CTEs in one WITH clause, separated by commas, and later CTEs can reference earlier ones. This turns a deeply nested query into a readable sequence of clearly named steps, each of which can also be tested independently by simply running just that subquery.

Recursive CTEs

A recursive CTE uses WITH RECURSIVE name AS (base_case UNION ALL recursive_case). The base case produces the starting rows; the recursive case refers back to the CTE itself and keeps running, accumulating results, until it produces no more new rows. This is the standard way to query hierarchical data, such as an employee-to-manager reporting chain or a category tree with arbitrary nesting depth.

CTEs vs. Subqueries: Readability, Not Always Performance

In modern PostgreSQL (12 and later), a non-recursive CTE is generally inlined into the main query by the planner just like a subquery would be, unless it is referenced multiple times, is recursive, or you explicitly force materialization with MATERIALIZED. So the main reason to reach for a CTE today is clarity of expression, not a guaranteed performance difference — though MATERIALIZED is available when you specifically want the CTE computed once and reused.

Examples

A readable multi-step query using WITH

SQL
WITH expensive_products AS (
    SELECT id, name, price
    FROM products
    WHERE price > 500
),
by_category AS (
    SELECT category_id, COUNT(*) AS expensive_count
    FROM products
    JOIN expensive_products USING (id)
    GROUP BY category_id
)
SELECT c.name, b.expensive_count
FROM by_category b
JOIN categories c ON c.id = b.category_id;
Output
   name   | expensive_count
----------+-----------------
 Laptops  |               1

A recursive CTE walking an employee reporting chain

SQL
WITH RECURSIVE reports_to (id, name, manager_id, depth) AS (
    SELECT id, name, manager_id, 0
    FROM employees
    WHERE manager_id IS NULL          -- base case: the top of the chain
    UNION ALL
    SELECT e.id, e.name, e.manager_id, r.depth + 1
    FROM employees e
    JOIN reports_to r ON e.manager_id = r.id
)
SELECT * FROM reports_to ORDER BY depth;
Output
 id |   name   | manager_id | depth
----+----------+------------+-------
  1 | Priya    |            |     0
  2 | Karan    |          1 |     1
  3 | Sanya    |          2 |     2

Common Mistakes

  • Writing a recursive CTE without a terminating condition in the base case (or a naturally shrinking recursive step), causing it to loop until it hits the recursion limit.
  • Assuming a CTE always materializes and acts as a performance-isolating boundary — since PostgreSQL 12, non-recursive CTEs are usually inlined and optimized like ordinary subqueries.
  • Referencing a later CTE from an earlier one, which is not allowed — CTEs in a WITH clause can only reference ones defined before them (except within WITH RECURSIVE's own self-reference).
  • Forgetting UNION ALL (not UNION) in a recursive CTE, which adds an unnecessary and potentially expensive duplicate-elimination step on every iteration.

Exercise

Each employee has a manager_id. Using a recursive CTE, list Anil and then everyone above him in the reporting chain, from Anil up to the top.

Expected output: name, Anil, Asha, Zoya (one per line)

SQL · starter code
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, manager_id INTEGER);
INSERT INTO employees (id, name, manager_id) VALUES
  (1, 'Zoya', NULL),
  (2, 'Asha', 1),
  (3, 'Ravi', 1),
  (4, 'Anil', 2);

-- TODO: a recursive CTE that starts at Anil and follows manager_id upwards
Show hints
  • The anchor part selects Anil, with a level of 1. The recursive part joins employees to the CTE on e.id = chain.manager_id.
  • The two parts are combined with UNION ALL; ordering by the level gives the chain from the bottom up.
Show solution
SQL · solution
CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, manager_id INTEGER);
INSERT INTO employees (id, name, manager_id) VALUES
  (1, 'Zoya', NULL),
  (2, 'Asha', 1),
  (3, 'Ravi', 1),
  (4, 'Anil', 2);

WITH RECURSIVE chain AS (
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE name = 'Anil'

  UNION ALL

  SELECT e.id, e.name, e.manager_id, chain.level + 1
  FROM employees e
  JOIN chain ON e.id = chain.manager_id
)
SELECT name
FROM chain
ORDER BY level;

Quick quiz

1. Which keyword introduces a common table expression?

  • A. DEFINE
  • B. TEMP
  • C. WITH
  • D. USING
Show answer

C. WITH name AS (SELECT ...) defines it, and the main query follows.

2. What are the two parts of a recursive CTE?

  • A. A SELECT and an UPDATE
  • B. An anchor query and a recursive query that refers to the CTE itself, joined by UNION ALL
  • C. Two identical queries
  • D. A table and an index
Show answer

B. The recursion stops when the recursive part returns no new rows.

3. How long does a CTE exist?

  • A. Only for the single statement in which it is defined
  • B. Until the session ends
  • C. Permanently, like a view
  • D. Until the next commit
Show answer

A. For reuse across statements, a view or a temporary table is needed.

Interview questions

What is a CTE and why use one instead of a subquery?

Show answer

A common table expression is a named result set defined with WITH at the start of a statement and used like a table in the query that follows. It makes a complex query readable by giving each step a name and letting the steps appear in order, and the same CTE can be referred to several times. A recursive CTE can also traverse hierarchies, which a subquery cannot.

Are CTEs slower than subqueries in PostgreSQL?

Show answer

Not since version 12. Before that, a CTE was always computed separately and acted as an optimisation barrier, so filters from the outer query could not be pushed into it. From PostgreSQL 12, a non-recursive CTE that is referenced once and has no side effects is inlined and optimised like a subquery. The old behaviour can be requested with MATERIALIZED, and prevented with NOT MATERIALIZED.

Key Points to Remember

  • WITH defines named, reusable subqueries that make complex SQL easier to read and reason about.
  • WITH RECURSIVE enables querying hierarchical/graph-like data such as org charts or category trees.
  • Since PostgreSQL 12, non-recursive CTEs are typically inlined by the planner unless referenced multiple times or marked MATERIALIZED.
  • Recursive CTEs need a well-formed base case and recursive case using UNION ALL to avoid infinite loops.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering