P08 · SQL & Querying
SQL subqueries and CTEs — the one-row trap
How SQL subqueries and CTEs work — and the scalar-subquery trap that passes in dev and errors in production the day the data grows.
Start with the basics
A subquery is a query written inside another query — a lookup in the SELECT list, a filter in the WHERE, or a source table in the FROM.
A common table expression, or CTE, is the same idea with a name: you define it with a WITH clause and then use that name like a table — mostly for readability, so a long query reads top to bottom.
Subqueries and CTEs let you build complex logic in readable pieces. The traps are about how many rows a piece returns, and when the database runs it.
One that works
| id | amount |
|---|---|
| 1 | 120 |
| 2 | 80 |
| 3 | 200 |
WITH big AS (
SELECT id, amount FROM orders WHERE amount > 100
)
SELECT COUNT(*) AS n FROM big;| n |
|---|
| 2 |
The CTE named 'big' is a subquery with a name; the outer query counts its rows — two orders over 100.
A subquery is a query nested inside another, and a CTE is just a subquery given a name.
When to use it
- Reach for a subquery when one query needs the result of another — a lookup, a filter, or a derived table.
- Use a CTE to name that subquery when the query gets long or you reuse the piece.
- Use a recursive CTE for hierarchies like org charts or category trees.
That one rule — a single-value subquery must return exactly one row — is fine until the data grows. The day a lookup returns a second row, the query that passed every test errors in production. Here is how.
See it happen
The lookup that passes dev and dies in prod
You look up each item's price with a subquery in the SELECT list. Today every SKU has exactly one price, so it works — and both versions return the same rows now. The difference shows up later.
| id | sku |
|---|---|
| 1 | A |
| 2 | B |
| sku | cents |
|---|---|
| A | 100 |
| B | 200 |
scalar subquery — works today
SELECT id,
(SELECT cents FROM prices
WHERE prices.sku = items.sku) AS price
FROM items
ORDER BY id;| id | price |
|---|---|
| 1 | 100 |
| 2 | 200 |
join on the key — stays safe
SELECT i.id, p.cents AS price
FROM items i
JOIN prices p ON p.sku = i.sku
ORDER BY i.id;| id | price |
|---|---|
| 1 | 100 |
| 2 | 200 |
It reads correctly today — but the day a SKU gets a second price row, the scalar subquery errors in production; a join on the key stays safe.
Walk the full example in the unit →The rest of the family
More ways a subquery surprises you
A subquery that refers to the outer row re-runs for every outer row; EXISTS and IN also differ in how they treat missing values and in cost.
Whether a named subquery is inlined or computed once and reused depends on the database, so naming a query for readability can change its performance.
A recursive query needs a starting row, a step that references itself, and a termination — the shape behind org charts and paths.
A single-value subquery in the SELECT list returns nothing when there is no match, where an inner join would drop the row — so “rewritten for speed” can silently lose entities.
EXISTS only tests whether rows exist, never their values, so what you put in its SELECT list makes no difference at all.
Self-check
How to spot it in your own SQL
- A query that worked on sample data errors once the real data grows.
- You put a lookup inside the SELECT list as a subquery.
- You rewrote a subquery as a join “for speed” and the row count changed.
- A query is slow and has a subquery that references the outer row.
What people miss
Subqueries and CTEs are where “it works on my machine” comes from — a single-value subquery that returns one row today and two tomorrow, a rewrite-for-speed that quietly drops rows. It passes review and breaks in production.
Go deeper
Ask Colearn
Instant answers, grounded in the same verified material the diagnostic grades against.
Why does my subquery work in dev but error in prod?
A subquery used as a single value must return exactly one row. It works while your test data has one match per row, then errors the day real data produces a second match.
Join on the key instead, so extra matches surface as extra rows rather than a crash — or guarantee the subquery returns one row.
from the unit →What's the difference between a subquery and a CTE?
A CTE is just a subquery with a name, defined up front with a WITH clause so a long query reads top to bottom.
Whether the database inlines it or computes it once and reuses it is engine-dependent, so naming a query for readability can change its performance.
from the unit →Why did rows disappear when I turned my subquery into a join?
A single-value subquery in the SELECT list returns nothing when there is no match, so the row still appears (with a blank). An inner join drops rows that do not match.
If you need to keep every row, use a LEFT join rather than an inner one when you rewrite.
from the unit →Does what I SELECT inside EXISTS matter?
No — EXISTS only checks whether any row exists, never the values, so its SELECT list is never actually evaluated.
Selecting 1, selecting everything, or anything else inside EXISTS all behave identically; pick whatever reads clearest.
from the unit →That’s the free taste