SQL Subqueries, CTEs and Set Operations

A subquery is a query inside another query. It can produce one value to compare against, a yes/no answer for each row, or a whole table to join. A common table expression (CTE), written with WITH, gives a subquery a name so that the main query reads top to bottom. A recursive CTE can follow a chain of rows, such as an employee's managers, to any depth. Set operations combine the results of two queries.

The examples use the schema from basics, and every result shown is PostgreSQL 17's output.

Scalar subqueries: one value

A subquery in parentheses that returns one row and one column can be used anywhere a value can:

SELECT name, salary
FROM employees
WHERE salary > (SELECT avg(salary) FROM employees)
ORDER BY id;
 name | salary
------+--------
 Ada  |   9000
 Bob  |   7000
 Cat  |   7000

The average salary is about 6214, and three employees earn more.

If the subquery returns more than one row, the query fails when it runs:

... WHERE salary = (SELECT salary FROM employees WHERE dept_id = 1);
ERROR:  more than one row returned by a subquery used as an expression

Department 1 has three salaries, so there is no single value to compare with. IN (subquery) compares against a list instead. If the subquery returns no rows, its value is NULL.

Correlated subqueries

A correlated subquery refers to a column of the outer query, so it is evaluated once for each outer row, with that row's values:

SELECT name, salary, dept_id
FROM employees e
WHERE salary > (SELECT avg(salary) FROM employees x
                WHERE x.dept_id = e.dept_id)
ORDER BY id;
 name | salary | dept_id
------+--------+---------
 Ada  |   9000 |       1
 Dan  |   6000 |       2

For each employee e, the subquery computes the average of e's department. Ada earns more than Engineering's 7667, and Dan more than Sales's 5500.

Gus does not appear, and the reason is NULL. His dept_id is NULL, so x.dept_id = NULL matches no row, the subquery's average is NULL, and 4000 > NULL is unknown.

A correlated subquery can also produce a column for each row:

SELECT name,
       (SELECT count(*) FROM assignments a WHERE a.emp_id = e.id) AS projects
FROM employees e ORDER BY id;
 name | projects
------+----------
 Ada  |        1
 Bob  |        2
 Cat  |        1
 Dan  |        0
 Eve  |        1
 Fay  |        0
 Gus  |        1

The planner often turns a correlated subquery into a join, so "once per row" describes the result, not necessarily how it is computed. EXPLAIN shows what it actually does (query planning).

EXISTS: does a matching row exist

SELECT name FROM employees e
WHERE EXISTS (SELECT 1 FROM assignments a WHERE a.emp_id = e.id)
ORDER BY id;
 name
------
 Ada
 Bob
 Cat
 Eve
 Gus

EXISTS is true when the subquery returns at least one row. Its columns are never read, which is why SELECT 1 is the usual way to write it. Bob has two projects and still appears once. A join with assignments would list him twice, which is the row duplication shown in joins. NOT EXISTS and its advantage over NOT IN are also covered there.

Derived tables: a subquery in FROM

A subquery in FROM acts as a temporary table for the rest of the query. It must have an alias:

SELECT d.name, t.total
FROM departments d
JOIN (SELECT dept_id, sum(salary) AS total
      FROM employees GROUP BY dept_id) t ON t.dept_id = d.id
ORDER BY d.id;
    name     | total
-------------+-------
 Engineering | 23000
 Sales       | 11000
 HR          |  5500

Grouping inside the subquery is the fix for the inflated total in joins: each table is aggregated to one row per department before the join, so no row is repeated.

CTEs: naming subqueries with WITH

A CTE defines one or more named queries before the main query. The derived table above, with a second total added:

WITH dept_totals AS (
    SELECT dept_id, sum(salary) AS total
    FROM employees GROUP BY dept_id
),
dept_hours AS (
    SELECT e.dept_id, sum(a.hours) AS hours
    FROM employees e JOIN assignments a ON a.emp_id = e.id
    GROUP BY e.dept_id
)
SELECT d.name, t.total, COALESCE(h.hours, 0) AS hours
FROM departments d
JOIN dept_totals t     ON t.dept_id = d.id
LEFT JOIN dept_hours h ON h.dept_id = d.id
ORDER BY d.id;
    name     | total | hours
-------------+-------+-------
 Engineering | 23000 |    75
 Sales       | 11000 |    25
 HR          |  5500 |     0

Salaries and hours are each summed at their own level and then joined one row per department, so Engineering's total stays 23000, not the 30000 of the direct join.

A CTE is mainly about readability: each step has a name, and a later CTE can use an earlier one.

Does PostgreSQL compute a CTE separately?

Since PostgreSQL 12, a CTE that is used once and has no side effects is folded into the main query, like a subquery. The planner can then push conditions into it. With WHERE id = 3 on a CTE over employees, the plan is a single index lookup:

Index Scan using employees_pkey on employees
  Index Cond: (id = 3)

AS MATERIALIZED forces the CTE to be computed in full first. The same query then reads the whole table and filters afterwards:

CTE Scan on t
  Filter: (id = 3)
  CTE t
    ->  Seq Scan on employees

Materializing helps when an expensive CTE is used several times, and hurts when a filter outside could have cut it down. Versions before 12 always materialized.

Recursive CTEs

WITH RECURSIVE defines a query in terms of itself. It has two parts joined by UNION ALL:

  • the anchor, a normal query that gives the starting rows;
  • the recursive part, which refers to the CTE's own name and produces new rows from the rows found in the previous round.

The database runs the anchor, then runs the recursive part on the new rows, again and again, until a round produces no rows.

The simplest example counts from 1 to 5:

WITH RECURSIVE n(i) AS (
    SELECT 1                              -- anchor
    UNION ALL
    SELECT i + 1 FROM n WHERE i < 5       -- recursive part
)
SELECT i FROM n;

This returns 1, 2, 3, 4, 5. The condition i < 5 stops it. Without a stop condition the query runs forever.

Walking a hierarchy

Every employee's level in the management tree, starting from the people with no manager:

WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 0 AS depth
    FROM employees WHERE manager_id IS NULL                  -- anchor: the top
    UNION ALL
    SELECT e.id, e.name, e.manager_id, c.depth + 1
    FROM employees e JOIN chain c ON e.manager_id = c.id     -- one level down
)
SELECT name, depth FROM chain ORDER BY depth, id;
 name | depth
------+-------
 Ada  |     0
 Dan  |     0
 Fay  |     0
 Bob  |     1
 Cat  |     1
 Eve  |     1
 Gus  |     1

Round 1 (the anchor) finds Ada, Dan and Fay. Round 2 finds everyone whose manager was found in round 1. Round 3 finds no one, so the recursion stops.

Going up instead, from one employee to the top, builds a path:

WITH RECURSIVE up AS (
    SELECT id, name, manager_id, name::text AS path
    FROM employees WHERE id = 5
    UNION ALL
    SELECT m.id, m.name, m.manager_id, up.path || ' -> ' || m.name
    FROM employees m JOIN up ON m.id = up.manager_id
)
SELECT path FROM up WHERE manager_id IS NULL;
    path
------------
 Eve -> Dan

This is a BFS over the rows, one level per round (graphs). On data with a cycle, such as two employees who manage each other, it never stops. PostgreSQL 14 added a CYCLE clause to detect that. Using UNION instead of UNION ALL also stops it when a round produces only rows that were already found.

Set operations

Set operations combine the rows of two queries with the same number of columns:

Operation Result
UNION rows in either result, duplicates removed
UNION ALL rows in either result, duplicates kept
INTERSECT rows in both results
EXCEPT rows in the first result but not the second

SELECT name FROM employees WHERE dept_id = 1
UNION
SELECT name FROM employees WHERE salary < 5000
ORDER BY 1;
 name
------
 Ada
 Bob
 Cat
 Gus

UNION removes duplicates, which takes a sort or a hash over the whole result. UNION ALL skips that, so it is the one to use when duplicates are impossible or wanted.

SELECT id FROM departments EXCEPT SELECT dept_id FROM employees;
 id
----
  4

Department 4, Legal, has no employees. The right side contains NULL from Gus, yet EXCEPT still returns 4. Set operations treat NULLs as equal to each other, as DISTINCT does, so they are not affected by the NULL problem of NOT IN.