iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Subqueries

Subqueries, derived tables, lateral joins, and CTEs in Postgres. The vocabulary for composing a clean query out of layered ideas.

PostgreSQL — subqueries + CTEs

EXAMPLE
-- ===== Scalar subquery =====
-- Returns a single value; usable wherever a value goes.
SELECT name, (SELECT AVG(total) FROM orders) AS avg_total
FROM users;

-- ===== IN / NOT IN subquery =====
SELECT id, name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- Beware: NULL inside the IN list makes NOT IN return UNKNOWN. Prefer NOT EXISTS.

-- ===== EXISTS / NOT EXISTS =====
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- Often faster than IN on large sets; planner handles correlation cleanly.

-- ===== ANY / ALL =====
SELECT id FROM orders WHERE total > ALL (SELECT credit_limit FROM users);
SELECT id FROM orders WHERE user_id = ANY (ARRAY[1, 2, 3]);

-- ===== Derived table (subquery in FROM) =====
SELECT u.name, t.total_spent
FROM users u
JOIN (
  SELECT user_id, SUM(total) AS total_spent
  FROM orders
  GROUP BY user_id
) t ON t.user_id = u.id
WHERE t.total_spent > 100;

-- ===== CTE (WITH) =====
WITH big_spenders AS (
  SELECT user_id, SUM(total) AS total_spent
  FROM orders
  GROUP BY user_id
  HAVING SUM(total) > 100
)
SELECT u.name, b.total_spent
FROM users u
JOIN big_spenders b ON b.user_id = u.id;

-- CTEs read top-down. Postgres 12+ inlines them (treats them like derived tables)
-- unless you use MATERIALIZED. Use MATERIALIZED to force a temp result:
WITH MATERIALIZED big AS (
  SELECT * FROM orders WHERE total > 100
)
SELECT * FROM big WHERE created_at >= now() - interval '1 day';

-- ===== Recursive CTE =====
WITH RECURSIVE org AS (
  SELECT id, manager_id, name, 1 AS depth FROM employees WHERE id = 1
  UNION ALL
  SELECT e.id, e.manager_id, e.name, o.depth + 1
  FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT * FROM org ORDER BY depth;

-- ===== LATERAL =====
-- Subquery on the right side of a join that can reference left columns.
SELECT u.name, recent.total, recent.created_at
FROM users u,
LATERAL (
  SELECT total, created_at
  FROM orders o
  WHERE o.user_id = u.id
  ORDER BY created_at DESC
  LIMIT 1
) recent;

-- LATERAL is great for 'per-row top-N' and 'expand JSON arrays'.

-- ===== Correlated subquery =====
SELECT u.id, u.name,
       (SELECT MAX(total) FROM orders o WHERE o.user_id = u.id) AS biggest
FROM users u;

-- Often slower than the equivalent JOIN + GROUP BY; verify with EXPLAIN.

-- ===== When to use each =====
-- IN / EXISTS         filtering rows by existence
-- Derived table       compute aggregates then join
-- CTE                 readability + recursion
-- LATERAL             per-row computations / expansions
-- Correlated subquery occasional inline lookup; check perf

-- ===== Patterns to internalise =====
-- - EXISTS over IN for nullable column inputs
-- - CTEs for readability, but check EXPLAIN
-- - LATERAL for top-N-per-group
-- - Inline aggregate via derived table; do not repeat the same SUM twice in SELECT

-- ===== Pitfalls =====
-- - NOT IN with a NULL inside -> always false
-- - Correlated subqueries firing per row on huge datasets
-- - CTEs that PREVENT predicate pushdown if MATERIALIZED needlessly
-- - Recursive CTEs without a depth limit -> infinite loops

Why it matters

Subqueries, derived tables, CTEs, and LATERAL are the composition vocabulary of clean SQL. EXISTS over IN, LATERAL for per-row top-N, CTEs for readability and recursion. The planner usually does the right thing; always confirm with EXPLAIN on hot queries.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
SELECT *
FROM users
WHERE id IN (SELECT user_id FROM posts GROUP BY user_id HAVING count(*) > 10);
Try it Yourself »

Discussion

Loading…