PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 8: Subqueries

Subqueries, NOT IN and LATERAL

Level 2: Querying25 min

Subqueries let a query ask another query a question. Scalar, IN and EXISTS subqueries cover most needs; LATERAL lets a subquery in FROM refer to earlier tables, which unlocks "top N per row" patterns.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Write scalar, IN, EXISTS and correlated subqueries
  • Explain why NOT IN returns nothing when the list contains NULL
  • Use LATERAL for per-row subqueries such as the latest order per customer

conceptThe concept

A scalar subquery returns one value and can appear wherever an expression can; it is an error if it returns more than one row. x IN (subquery) and EXISTS (subquery) test membership. A correlated subquery refers to the outer row and is logically run once per outer row; the planner often turns it into a join.

NOT IN is the trap. x NOT IN (1, NULL) means x <> 1 AND x <> NULL, and the second part is unknown, so the whole predicate is never true. One NULL in the subquery result empties the answer. NOT EXISTS does not have this problem.

LATERAL in FROM lets a subquery reference columns of tables listed before it. FROM customers c CROSS JOIN LATERAL (SELECT ... WHERE o.customer_id = c.id ORDER BY ... LIMIT 3) runs the inner query per customer. Use LEFT JOIN LATERAL ... ON true to keep rows where the subquery returns nothing.

whyWhy it matters

The NOT IN bug is a production classic: a report works for months until one NULL arrives in the subquery's column, and then it silently returns zero rows. LATERAL is the cleanest way to write per-group limits that use an index.

Use caseReal-world scenario

The support dashboard shows each platinum customer with their three most recent orders. LATERAL with ORDER BY and LIMIT expresses this directly, and with an index on (customer_id, created_at) each lookup is a short index scan.

Try itExamples

Run and edit these in the interactive lesson.

Scalar and IN subqueries
SELECT name, price,
       round(price - (SELECT avg(price) FROM products), 2) AS vs_average
FROM products
WHERE id IN (SELECT product_id FROM reviews WHERE rating = 5 GROUP BY product_id HAVING count(*) >= 15)
ORDER BY price DESC
LIMIT 5;
Correlated subquery
SELECT p.category, p.name, p.price
FROM products p
WHERE p.price > (SELECT avg(p2.price) * 2 FROM products p2 WHERE p2.category = p.category)
ORDER BY p.category, p.price DESC;

Employees who manage nobody. The subquery returns the CEO's NULL manager_id, so NOT IN finds nothing.

The NOT IN trap
SELECT count(*) AS not_in
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);

SELECT count(*) AS not_exists
FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.id);
LATERAL: latest two orders per platinum customer
SELECT c.name, recent.id AS order_id, recent.created_at::date AS day, recent.total
FROM customers c
CROSS JOIN LATERAL (
  SELECT o.id, o.created_at, o.total
  FROM orders o
  WHERE o.customer_id = c.id
  ORDER BY o.created_at DESC
  LIMIT 2
) AS recent
WHERE c.tier = 'platinum'
ORDER BY c.name, recent.created_at DESC
LIMIT 10;
LEFT JOIN LATERAL keeps rows with no match
SELECT c.id, c.name, last_order.created_at::date AS last_order
FROM customers c
LEFT JOIN LATERAL (
  SELECT created_at FROM orders o WHERE o.customer_id = c.id ORDER BY created_at DESC LIMIT 1
) last_order ON true
WHERE c.country = 'FR' AND c.id > 1800
ORDER BY c.id
LIMIT 8;

Avoid / preferDo this, not that

Avoid
SELECT name FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
Prefer
SELECT e.name FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.id);

The CEO has a NULL manager_id, and NOT IN against a list containing NULL is never true, so the first query returns nothing.

Avoid
SELECT c.name,
  (SELECT max(created_at) FROM orders o WHERE o.customer_id = c.id),
  (SELECT total FROM orders o WHERE o.customer_id = c.id ORDER BY created_at DESC LIMIT 1)
FROM customers c;
Prefer
SELECT c.name, last.created_at, last.total
FROM customers c
LEFT JOIN LATERAL (
  SELECT created_at, total FROM orders o
  WHERE o.customer_id = c.id ORDER BY created_at DESC LIMIT 1
) last ON true;

Two correlated subqueries run two lookups per customer and can disagree on ties. One LATERAL subquery returns both columns from the same row.

PitfallsCommon mistakes

  • Using NOT IN against a column that can be NULL. Use NOT EXISTS.
  • Writing a scalar subquery that can return two rows. It works in testing and fails in production with "more than one row returned by a subquery".
  • Using CROSS JOIN LATERAL when the subquery might be empty and you still want the outer row. Use LEFT JOIN LATERAL ... ON true.

LimitsWhen not to use it

When a correlated subquery runs once per row of a large outer result, a join with GROUP BY is often far cheaper. Check the plan for a SubPlan executed thousands of times.

SecuritySecurity notes

Keep permission checks in the same statement as the data access (WHERE EXISTS (SELECT 1 FROM grants ...)), not in a separate earlier query, so a concurrent revoke cannot slip between the check and the read.

ChallengeTry it yourself

Count customers whose most recent order was cancelled.

Show one solution
One solution
SELECT count(*) AS customers_last_cancelled
FROM customers c
CROSS JOIN LATERAL (
  SELECT status FROM orders o
  WHERE o.customer_id = c.id
  ORDER BY created_at DESC
  LIMIT 1
) last
WHERE last.status = 'cancelled';

InterviewQuestions you should be able to answer

Why can NOT IN return no rows, and what do you use instead?
x NOT IN (a, b, NULL) expands to x <> a AND x <> b AND x <> NULL. The last comparison is unknown, so the AND is at best unknown and WHERE drops the row. Any NULL in the subquery result empties the result set. NOT EXISTS checks for the existence of a matching row and is unaffected by NULLs; it also plans as an anti-join.
What does LATERAL do?
It lets a subquery or set-returning function in FROM refer to columns of items that appear earlier in the same FROM. It is evaluated per row of the preceding items, which enables per-row top-N (ORDER BY ... LIMIT inside), calling functions with row values, and unnesting per row. LEFT JOIN LATERAL ... ON true keeps outer rows when the subquery is empty.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation