A real business problem — told through a shopping mall — that teaches you keys, relationships, joins, and aggregation, all at once.
You want to track every customer visit, and every transaction that happens during that visit — so you build two systems.
| visit_id | customer_id |
|---|---|
| 1 | 23 |
| 2 | 9 |
| 4 | 30 |
| 5 | 54 |
| 6 | 96 |
| 7 | 54 |
| 8 | 54 |
Stores an auto-generated visit_id and the customer_id for every entry.
| transaction_id | visit_id | amount |
|---|---|---|
| 2 | 5 | 310 |
| 3 | 5 | 300 |
| 9 | 5 | 200 |
| 12 | 1 | 910 |
| 13 | 2 | 970 |
Stores a transaction_id, the visit_id it happened during, and the amount.
"Find customers who visited the mall but did not purchase anything, and tell me how many such visits each customer had."
Customers who never visited the mall at all — that's a different question entirely, and it's not what's being asked.
Visits that happened, but ended with zero transactions — counted per customer.
Seven concepts, in order, take us from "two random tables" to the final query.
Every transaction points back to exactly one visit — through visit_id.
Uniquely identifies one visit. No two rows in Visits share the same visit_id.
Points back to a visit_id in Visits. Multiple transactions can share the same one.
Since one visit can have many transactions (or none), but every transaction belongs to exactly one visit — this is a one-to-many relationship, from Visits → Transactions.
...or it can match zero rows — like visit_id 4, 6, 7, 8. That's exactly the case this problem is hunting for.
We need every visit to survive the join — even the ones with zero matching transactions.
Keeps only visits that do have a matching transaction. Visits with zero purchases vanish completely.
✗ loses exactly the rows we needKeeps every row from Visits, and fills in NULL on the Transactions side when there's no match.
Keeps every row from Transactions instead — but a transaction can't exist without a visit, so this doesn't help here.
✗ wrong side of the relationshipAfter the LEFT JOIN, we filter down to rows where the transaction side is NULL, then group by customer_id and count.
They look similar. They are not always the same.
COUNT(*)
Counts every row in the group, regardless of NULLs in any column.
COUNT(1)
Counts every row too — 1 is just a constant, evaluated per row. Behaves the same as COUNT(*) in practice.
COUNT(column)
Counts only rows where that column is NOT NULL. This is exactly why we use COUNT(v.visit_id) here instead of COUNT(*).
It runs in this logical order — which is exactly why WHERE can filter on the join result before grouping even happens.
Start with the base table.
Attach matching transactions — or NULL if none exist.
Keep only the visits that had no match.
Bucket the remaining visits by customer.
Not needed here — we're not filtering the grouped result further.
Return the customer and their no-transaction visit count.
Not required — the problem accepts any order.
| customer_id | count_no_trans |
|---|---|
| 54 | 2 |
| 30 | 1 |
| 96 | 1 |
Keys, joins, and execution order aren't trivia. They're how you translate a real manager's question into a correct query.