Joins, subqueries, CTEs and LATERAL

Every join type in practice, correlated subqueries, common table expressions, recursive CTEs, LATERAL joins and RETURNING.

Joins and subqueries

-- inner: only matching rows
select b.title, a.name
from book b join author a on a.id = b.author_id;

-- left: keep books even with no reviews
select b.title, count(r.id) as reviews
from book b
left join review r on r.book_id = b.id
group by b.id, b.title;

-- anti-join: authors with no books, expressed two ways
select a.* from author a left join book b on b.author_id = a.id where b.id is null;
select a.* from author a where not exists (select 1 from book b where b.author_id = a.id);

-- semi-join
select a.* from author a where exists (select 1 from book b where b.author_id = a.id);

-- full outer join: reconcile two sources
select coalesce(x.isbn, y.isbn) as isbn, x.title as local, y.title as remote
from local_book x full join remote_book y using (isbn)
where x.isbn is null or y.isbn is null;
JoinKeepsTypical use
inner joinMatches onlyRequired relations
left joinAll left rowsOptional relations, counts
full joinAll rows from bothReconciling two datasets
cross join lateralDepends on the lateral conditionTop-N per group, set-returning functions
not existsNo matching rowAnti-join; usually beats not in with nulls
⚠️
not in (subquery) returns no rows at all if the subquery yields a single NULL. not exists has no such trap and is usually planned better too, so reach for it by default.

CTEs and recursion

with recent as (
  select id, customer_id, total_cents
  from invoice
  where issued_at >= now() - interval '30 days'
),
totals as (
  select customer_id, sum(total_cents) as revenue, count(*) as invoices
  from recent
  group by customer_id
)
select c.name, t.revenue, t.invoices
from totals t join customer c on c.id = t.customer_id
order by t.revenue desc
limit 20;

-- recursive: an org chart
with recursive org as (
  select id, name, manager_id, 1 as depth
  from employee where manager_id is null
  union all
  select e.id, e.name, e.manager_id, o.depth + 1
  from employee e join org o on e.manager_id = o.id
  where o.depth < 10
)
select repeat('  ', depth - 1) || name as tree, depth from org order by depth, name;
  • Since PostgreSQL 12 a CTE referenced once is inlined by default, so it no longer acts as an optimisation fence. materialized forces the old behaviour and not materialized forces inlining.
  • A recursive CTE must have a termination condition. A cycle without one runs until it exhausts memory or the statement timeout.
  • Use union rather than union all in the recursive term if the set can produce duplicate rows and you want them eliminated - at the cost of a sort per iteration.
  • Add cycle detection in PostgreSQL 14+ with cycle id set is_cycle using path instead of tracking an array by hand.
-- data-modifying CTEs: several writes in one statement, all-or-nothing
with moved as (
  delete from staging.book where imported_at is not null
  returning *
)
insert into book (title, author_id)
select title, author_id from moved
returning id, title;

LATERAL and RETURNING

-- top 3 books per author: the classic lateral use
select a.name, b.title, b.published_on
from author a
cross join lateral (
  select title, published_on
  from book
  where author_id = a.id
  order by published_on desc nulls last
  limit 3
) b;

-- left join lateral keeps authors with no books
select a.name, b.title
from author a
left join lateral (
  select title from book where author_id = a.id order by id limit 1
) b on true;

-- expand a jsonb array into rows
select i.id, e.value->>'sku' as sku
from invoice i
cross join lateral jsonb_array_elements(i.metadata->'items') as e;
ClauseReturnsNote
returning *Inserted or updated rowsWorks on INSERT, UPDATE, DELETE and MERGE
returning old.* / new.*Both versionsPostgreSQL 18+ for UPDATE and DELETE
on conflict do updateUpsertReference the existing row via excluded
mergeRow-by-row actionsMore flexible than upsert; watch for concurrent-insert races
insert into book (isbn, title, author_id)
values ('9780441013593', 'The Dispossessed', 1)
on conflict (isbn) do update
  set title = excluded.title,
      updated_at = now()
returning id, title, (xmax = 0) as inserted;

FAQ

When is a CTE slower than a subquery?
When the planner would have pushed a predicate into the subquery but the CTE was materialised. Check the plan: if you see a CTE Scan with a large row count, try not materialized or move the filter into the CTE.
How do I update from another table?
Use update t set ... from other o where o.id = t.other_id. Everything the from list provides is joined into the update, and you must ensure the join matches at most one row per target or the result is arbitrary.

Window functions, full-text search and regex Query planning and performance tuning

Last refreshed 2026-09-18.