Module 5 · Intermediate querying
Sign in with email or GitHub to spin up your own Postgres sandbox and run the queries for this lesson.
Sign in to start the sandboxNo GitHub? Create an account with email.
In the subqueries lesson you nested a SELECT inside a FROM clause and it worked — but you also had to read it inside-out. A common table expression (CTE) fixes that: WITH name AS (...) gives a subquery a name, and the rest of the query uses that name like a table. Same plan, far better prose.
The seed is a tiny music-streaming service: artists have tracks, and every listen lands in plays with a timestamp.
SELECT a.name, a.genre, t.title
FROM artists a
JOIN tracks t ON t.artist_id = a.id
ORDER BY a.id, t.id;
Question: which artists are played more than the average artist? Written with nothing but subqueries, the same per-artist rollup gets pasted in twice and you read it from the innermost parentheses outward:
SELECT name, total_plays
FROM (
SELECT a.name, count(*) AS total_plays
FROM artists a
JOIN tracks t ON t.artist_id = a.id
JOIN plays p ON p.track_id = t.id
GROUP BY a.name
) AS per_artist
WHERE total_plays > (
SELECT avg(plays)
FROM (
SELECT count(*) AS plays
FROM artists a
JOIN tracks t ON t.artist_id = a.id
JOIN plays p ON p.track_id = t.id
GROUP BY a.id
) AS inner_counts
)
ORDER BY total_plays DESC;
It works, but the interesting logic — "compare each artist to the average" — is buried under two copies of the same three-table join. Change the rollup and you must remember to change it in both places.
WITHHere is the same query as a CTE. Define the rollup once, give it a name, and read top to bottom:
WITH artist_plays AS (
SELECT a.name, count(*) AS total_plays
FROM artists a
JOIN tracks t ON t.artist_id = a.id
JOIN plays p ON p.track_id = t.id
GROUP BY a.name
)
SELECT name, total_plays
FROM artist_plays
WHERE total_plays > (SELECT avg(total_plays) FROM artist_plays)
ORDER BY total_plays DESC;
Two things happened. The query now reads like a recipe — "first compute artist_plays, then keep the above-average ones". And notice artist_plays appears twice in the main query: once in FROM, once inside the scalar subquery that computes the average. Define once, reference as often as you like — the duplication from the nested version is gone.
A WITH clause can hold several CTEs separated by commas, and each one can reference the ones before it. That turns a query into a pipeline: filter, then aggregate, then join back. Which tracks took off in the second half of June?
WITH late_june AS (
SELECT * FROM plays WHERE played_at >= '2024-06-15'
),
track_counts AS (
SELECT track_id, count(*) AS plays
FROM late_june
GROUP BY track_id
)
SELECT t.title, a.name AS artist, tc.plays
FROM track_counts tc
JOIN tracks t ON t.id = tc.track_id
JOIN artists a ON a.id = t.artist_id
ORDER BY tc.plays DESC, t.title;
late_june filters, track_counts aggregates over late_june, and the main query joins the result back to tracks and artists for human-readable output. Each step is small enough to verify on its own — while debugging, you can replace the main query with SELECT * FROM track_counts and inspect the intermediate result directly.
Anywhere a table can appear, a CTE name can appear: in FROM, in a JOIN, inside a subquery. Here one feeds an IN list:
WITH heavy_rotation AS (
SELECT track_id
FROM plays
GROUP BY track_id
HAVING count(*) >= 4
)
SELECT title
FROM tracks
WHERE id IN (SELECT track_id FROM heavy_rotation)
ORDER BY title;
The CTE exists only for the duration of its statement — it is not a real table, leaves nothing behind, and needs no cleanup.
Does Postgres compute the CTE into a temporary buffer, or fold it into the main query? Since Postgres 12, a CTE that is referenced once (and has no side effects) is inlined — the planner treats it exactly like the equivalent subquery, so WHERE conditions push down and indexes get used. A CTE referenced multiple times is usually materialized once and reused.
You can override the default per CTE:
WITH artist_plays AS MATERIALIZED (
-- forced to compute once into a buffer, opaque to the planner
...
)
SELECT ...
AS MATERIALIZED forces the pre-12 behavior — occasionally useful as an optimizer fence when you want an expensive step computed exactly once. AS NOT MATERIALIZED forces inlining. In practice: write for readability first, and reach for these keywords only after EXPLAIN shows a problem.
A CTE body isn't limited to SELECT. It can wrap INSERT, UPDATE, or DELETE with a RETURNING clause, letting you modify rows and post-process the result in one statement. Say the service only keeps a rolling window of listening history — purge everything from before June 5 and report how much was removed, in one go:
WITH purged AS (
DELETE FROM plays
WHERE played_at < '2024-06-05'
RETURNING track_id
)
SELECT count(*) AS plays_removed FROM purged;
plays_removed is 4: the DELETE ran, and the outer SELECT counted its RETURNING output — no second query, no window where another session sees the rows half-gone. These are called data-modifying CTEs (and they are always materialized — the write happens exactly once).
There's also WITH RECURSIVE, where a CTE references itself to walk trees and graphs — that one earns its own lesson later in the course.
Using a chain of two CTEs, list each genre with its total play count, most-played first: first aggregate plays per track, then join that back through tracks to artists and roll up by genre. Try writing it yourself before peeking — here's one way:
WITH track_plays AS (
SELECT track_id, count(*) AS plays
FROM plays
GROUP BY track_id
)
SELECT a.genre, sum(tp.plays) AS total_plays
FROM track_plays tp
JOIN tracks t ON t.id = tp.track_id
JOIN artists a ON a.id = t.artist_id
GROUP BY a.genre
ORDER BY total_plays DESC;
Electronic should come out on top — Lumen's Afterglow carries the month.
WITH name AS (...) names a subquery; the main query uses the name like a table.AS MATERIALIZED / AS NOT MATERIALIZED override the default when EXPLAIN says so.WITH can also wrap INSERT/UPDATE/DELETE ... RETURNING; recursive CTEs come later in the course.Up next: window functions — aggregates that peek at neighboring rows without collapsing them.