Which Users Have Never Ordered? INNER JOIN, LEFT JOIN, and the NULL Side
An admin dashboard needs every user next to their most recent order. Two tables: users (id, email, created_at) and orders (id, user_id, total, created_at), related by the foreign key orders.user_id. The instinct — select users, loop, select each user's orders — is the N+1 pattern this topic kills at the end. One query can do it, and the only decision that matters is which join.
An INNER JOIN returns only rows with a match on both sides. Users with zero orders simply vanish from the result. Nothing errors, nothing warns — the dashboard just quietly shows 847 of 1,000 users, and you discover the gap when someone asks why the newest signup is missing.
A LEFT JOIN keeps every row of the left table, filling the right side's columns with NULL when there is no match. That NULL is not missing data — it is the answer to "does a match exist?".
$sql = 'SELECT u.id, u.email,
o.id AS order_id, o.total, o.created_at AS last_order_at
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.created_at DESC';
$rows = $pdo->query($sql)->fetchAll();
The same skeleton answers three different questions depending on two small edits:
| Question | Query shape | How you spot the answer |
|---|---|---|
| Which users have ordered? | INNER JOIN |
every returned row has order_id |
| Which users have never ordered? | LEFT JOIN + WHERE o.id IS NULL |
the NULL side isolates the non-matches |
| All users, orders shown when they exist? | LEFT JOIN, no filter |
order_id === null in PHP marks a never-ordered user |
Test the NULL check on the right table's key column (o.id or o.user_id), not on a nullable-by-design column like o.total — a real order with a NULL total would be misclassified as "no order".
Two mechanical details interviewers probe. First, aliases: once you write FROM users u LEFT JOIN orders o, you must qualify every column that exists in both tables — id alone is ambiguous and MySQL rejects the query with Column 'id' in field list is ambiguous. Get in the habit of aliasing and qualifying; it also shortens queries enough that nested subqueries stay readable.
Second, the old comma syntax deserves a warning because you will meet it in legacy code:
-- Same join, two styles — MySQL optimizes them identically
SELECT u.email, o.total FROM users u, orders o WHERE o.user_id = u.id;
SELECT u.email, o.total FROM users u INNER JOIN orders o ON o.user_id = u.id;
The comma style states the relationship inside WHERE, mixed in with your filters. Remove or mistype one filter and the query still runs — it degrades into a cross join (every user × every order: 1,000 users × 50,000 orders = 50 million rows) with no error beyond a very slow query and an exhausted memory limit. JOIN ... ON makes the relationship structural; a WHERE clause can only ever remove rows, never accidentally create them. Cross joins are occasionally what you want — a sizes × colors product matrix, for example — but they should be deliberate.
One last property you must be able to state from memory, because everything in the next two chapters depends on it: a join multiplies rows, it does not nest them. A user with 3 orders appears in 3 result rows, each repeating the user's email. The dashboard that shows "users with their orders" has to either pick one order per user (window functions or PHP-side selection) or group the rows — and grouping by hand is where COUNT and SUM start lying.
COUNT(*) That Lies and SUM That Doubles: Aggregating Across a Join
The finance dashboard asks for "orders and revenue per user". The schema has three levels — users, orders (user_id, total), order_items (order_id, price) — and the first query anyone writes is the three-way join with aggregates slapped on top:
-- BROKEN: two one-to-many joins multiply rows before the aggregate runs
SELECT u.email, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY u.id, u.email;
Walk one concrete case through the row math. User 42 has one $50 order containing three items. The orders join produces one row; the order_items join triples it to three — one per item, each repeating the $50 total. COUNT(*) therefore says 3 orders, and SUM(o.total) says $150 for a single $50 order. Every order with N items is counted N times in both columns, and the report is wrong in direct proportion to average items per order. The join multiplied rows before GROUP BY saw them; aggregates can only count the rows they are given.
Three fixes, in order of how often you will reach for them:
1. Pre-aggregate in a derived table, then join the summary. Each level is collapsed to one row per parent before the joins meet, so nothing multiplies:
SELECT u.email, COALESCE(s.orders, 0) AS orders, COALESCE(s.revenue, 0) AS revenue
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) AS orders, SUM(total) AS revenue
FROM orders GROUP BY user_id
) s ON s.user_id = u.id;
COALESCE replaces the NULLs the LEFT JOIN produced for users with no orders, so the dashboard shows 0, not blank.
2. Aggregate one level at a time in the outer query. If the report only needs order-level facts, aggregate the item detail away in a subquery per order, then sum that.
3. COUNT(DISTINCT ...) when you truly need one count across a multiplied join. COUNT(DISTINCT o.id) counts each order once no matter how many item rows it spawned. It fixes counts; it cannot fix SUM — there is no SUM(DISTINCT o.total) shortcut that works, because two different orders can legitimately have the same total and DISTINCT would wrongly collapse them. For sums, restructure the query (fix 1), never deduplicate values.
Two distinctions inside the aggregates themselves come up constantly:
COUNT(*)versusCOUNT(o.id):COUNT(*)counts result rows;COUNT(column)counts non-NULL values of that column. On a LEFT JOIN'd result,COUNT(*)counts the never-ordered users' placeholder rows whileCOUNT(o.id)counts only real orders — both are correct answers to different questions, which is why a report that mixes them up looks almost right.HAVINGversusWHERE:WHEREfilters rows before grouping and cannot see aggregates;HAVINGfilters groups after. "Users with more than 5 orders totaling over $500" isGROUP BY u.id HAVING COUNT(*) > 5 AND SUM(o.total) > 500. Putting the aggregate condition inWHEREis a syntax error, and putting a row condition inHAVINGworks but scans more than it should.
The habit that prevents all of this: after writing any join feeding an aggregate, count the rows by hand for one concrete parent with multiple children — if the number is bigger than the parent count, your SUM is already wrong. With the reporting query now trustworthy, the last thing that can sink it is how many times per page you run it.
The 101-Query Page: Spotting and Killing N+1 in PDO
A page renders 50 users with their orders. The developer follows the "obvious" structure — get the users, then get each user's orders:
$users = $pdo->query('SELECT id, email FROM users ORDER BY created_at DESC LIMIT 50')
->fetchAll();
foreach ($users as $user) {
$stmt = $pdo->prepare('SELECT id, total FROM orders WHERE user_id = ?');
$stmt->execute([$user['id']]);
$user['orders'] = $stmt->fetchAll(); // one extra round trip per user
}
That is 1 query for the list plus N queries inside the loop — the N+1 query pattern. With 50 users the page issues 51 queries; with 500 it issues 501. Every individual query is fast — the slow query log stays clean — yet the page takes two seconds, because the cost is the round trips, serialized one after another. The signature is unmistakable in any profiler or MySQL general log: the same statement with a different bound parameter, over and over, and a query count that scales linearly with page size. This is also the shape most ORMs produce by default ("lazy loading"), which is why "what is the N+1 problem?" is one of the most-asked interview questions on data access.
The fix is one query per table, not one query per row. Two standard shapes:
// Shape A: one JOIN, then group the rows in PHP
$rows = $pdo->query(
'SELECT u.id, u.email, o.id AS order_id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id'
)->fetchAll();
$byUser = [];
foreach ($rows as $row) {
$byUser[$row['id']]['email'] = $row['email'];
$byUser[$row['id']]['orders'][] = [
'id' => $row['order_id'],
'total' => $row['total'],
];
}
The bucketing loop is the client-side half of the deal: the join returns the flat, multiplied shape from the first chapter, and PHP re-nests it — null order fields becoming an empty orders list for users who never ordered.
// Shape B: two queries, grouped the same way — constant at 2 regardless of page size
$orders = $pdo->prepare('SELECT id, user_id, total FROM orders WHERE user_id IN (...)');
Shape B matters because a JOIN is not always free. Joining two large one-to-many tables multiplies rows — 50 users × 200 orders each × 5 items is 50,000 rows shipped to PHP to render 50 users. When the child sets are big, fetch each table with its own query (the second one WHERE user_id IN (...) over the page's ids, built as one statement with a dynamically sized parameter list), then run the same bucketing loop. Two queries is still constant; N+1 is not.
Three supporting habits, each a common interview follow-up:
- Prepare outside the loop. If some logic genuinely requires a per-row query,
$pdo->prepare()at least once before the loop andexecute()inside — statement preparation is a round trip too. It softens the symptom; it does not cure N+1. - Order in SQL, not in PHP.
ORDER BY u.idin the query means the bucketing loop appends children in order; sorting after the fact is another O(N log N) surprise. - Bound the page. All three shapes assume a LIMIT-ed page. "Fetch all users and all their orders into memory" is N+1's cousin — the unbounded fetchAll.
Diagnose by counting: enable the general log or your profiler on a staging page and look for the repeated-statement signature. The query count should be a small constant — 2 or 3 for this page — and if it moves when you move the page-size slider, you are back in N+1 territory. Everything on this page eventually reaches the browser as HTML, and any user-controlled string in those rows — an email, a display name — must be handled by the rules in Escaping Output to Prevent XSS.
Quiz
1. A `users LEFT JOIN orders ON orders.user_id = users.id` returns a row where every orders.* column is NULL. What does that row represent?
2. You run `SELECT COUNT(*) FROM users u JOIN orders o ON o.user_id = u.id`. What does the result count?
3. A user has one $50 order containing three items. You run `SELECT u.email, o.total, oi.price FROM users u JOIN orders o ... JOIN order_items oi ...` and then `SUM(o.total)` grouped by user. What do you get for that user?
4. A page renders 50 users with their orders and the general log shows 51 queries. Which fix guarantees a constant query count as the page grows?
Flashcards
Question
INNER JOIN vs LEFT JOIN: what does each return, and how do you find the unmatched left rows?
Click to reveal answer
Answer
INNER JOIN returns only rows with a match in both tables — unmatched left rows disappear silently. LEFT JOIN keeps every left-table row, filling the right columns with NULL when there is no match; filtering `WHERE right.key IS NULL` on that NULL side isolates exactly the left rows that had no match (e.g., users who never ordered).
Question
Why alias tables in a join, and what happens to an unqualified ambiguous column?
Click to reveal answer
Answer
Aliases (FROM users u LEFT JOIN orders o) shorten multi-table queries and make nested subqueries readable; they also force you to qualify columns. A column present in both tables and referenced without a table prefix — bare `id` — is ambiguous, and MySQL rejects the query with "Column 'id' in field list is ambiguous".
Question
Why does SUM inflate across a join, and what is the correct fix?
Click to reveal answer
Answer
A one-to-many join multiplies the parent's row, repeating the parent's value on every child row, and SUM adds every repetition — one $50 order with 3 items sums to $150. COUNT(DISTINCT parent.id) rescues counts but there is no safe SUM(DISTINCT) shortcut; the real fix is pre-aggregating each level (GROUP BY in a derived table) before the joins meet.
Question
COUNT(*) vs COUNT(column) on a LEFT JOIN result — what is the difference?
Click to reveal answer
Answer
COUNT(*) counts result rows, including the placeholder rows the LEFT JOIN produced for non-matches; COUNT(column) counts only non-NULL values of that column, so on the NULL side it counts only real matches. Both are correct answers to different questions — total rows versus matched children — which is why mixing them produces reports that are almost right.
Question
What is the N+1 query pattern, how do you spot it, and what are the two standard fixes in PDO?
Click to reveal answer
Answer
One query for a list plus one query per item inside a loop (1 + N round trips, N growing with page size). Spot it in the general log or profiler as the same statement repeating with a different bound parameter, and in a query count that scales with page size. Fix: one JOIN then group rows by foreign key in PHP, or one query per table with WHERE id IN (...) over the page's ids, then the same grouping — plus preparing statements outside any loop that remains.
Revision Notes
Key Takeaways
- 1. INNER JOIN silently drops unmatched left rows; LEFT JOIN keeps them with NULL on the right side, and `WHERE right.key IS NULL` turns that NULL into the answer to 'which parents have no children?'
- 2. State the relationship in `JOIN ... ON`, not in a comma-join's WHERE — the comma form degrades into an accidental cross join the moment a predicate is missing
- 3. A join multiplies rows rather than nesting them: aggregates over a joined set count (and sum) the multiplied rows, so pre-aggregate each level in a derived table before joining
- 4. COUNT(*) counts rows and COUNT(col) counts non-NULL values — on a LEFT JOIN result they differ by the placeholder rows; COUNT(DISTINCT ...) fixes counts but no variant fixes SUM
- 5. N+1 is 1 + N round trips whose count scales with page size — kill it with one JOIN plus PHP bucketing, or one query per table with IN (...) over the page's ids
Interview Tips
- • "How do you find users who have never ordered?" — LEFT JOIN orders on user_id, then `WHERE orders.id IS NULL` on the NULL side; add that testing IS NULL against a nullable-by-design column like total would misclassify real rows
- • "COUNT(*) or COUNT(o.id)?" — count(*) counts result rows including LEFT JOIN placeholders; COUNT(o.id) counts only real matches; say when each is the right answer and that COUNT(DISTINCT ...) exists for counts across multiplied joins
- • "Why is my revenue double after adding the items table?" — walk the row multiplication: each item row repeats the order's total, SUM adds every copy; the fix is a pre-aggregated derived table per level, and note SUM(DISTINCT) is not a safe shortcut
- • "What is the N+1 problem and how do you fix it?" — define the 1 + N shape, cite the profiler symptom (same statement repeating, query count scaling with page size), and give both fixes (JOIN + client-side grouping, or IN (...) batch) plus eager loading as the ORM equivalent
Cheat Sheet
Joins with PDO
Join types:
| Need | Query | Tell-tale |
|---|---|---|
| Only matched pairs | INNER JOIN ... ON |
unmatched left rows vanish silently |
| Left rows always | LEFT JOIN ... ON |
right columns are NULL when no match |
| Non-matching left rows | LEFT JOIN + WHERE r.key IS NULL |
"users who never ordered" |
| Every × every (deliberate) | CROSS JOIN |
sizes × colors matrices |
Prefer JOIN ... ON over comma-joins: a missing WHERE predicate turns a comma-join into a 50-million-row cross join without an error.
Row math: a join multiplies — one user with 3 orders yields 3 rows, each repeating the user's columns. Aggregates count the rows they are given, so join first and aggregate later and SUM double-counts.
-- Correct per-user revenue: collapse each level BEFORE joining
SELECT u.email, COALESCE(s.orders, 0), COALESCE(s.revenue, 0)
FROM users u
LEFT JOIN (SELECT user_id, COUNT(*) AS orders, SUM(total) AS revenue
FROM orders GROUP BY user_id) s ON s.user_id = u.id;
COUNT(*)= result rows ·COUNT(col)= non-NULL values of col ·COUNT(DISTINCT col)= unique valuesWHEREfilters rows before grouping (no aggregates allowed) ·HAVINGfilters groups after- There is no safe
SUM(DISTINCT ...)fix — restructure the query instead
N+1: 1 list query + 1 query per row = N+1 round trips; query count scales with page size. Fix with one JOIN + PHP bucketing, or two queries with WHERE user_id IN (...) + the same bucketing. Prepare outside loops; order in SQL.