Products Without Sales
An easy SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- easy
- Seniority
- L3
- Asked in
- Natera
Problem
Which products have never appeared in a transaction? Return the product ID and product name.
Listed but never sold.
Tables
products
Columns: product_id INTEGER, product_name TEXT, category TEXT, price REAL, rating REAL, in_stock INTEGER
| product_id | product_name | category | price | rating | in_stock |
|---|---|---|---|---|---|
| 1001 | Basic Device 1X | Books | 17.52 | 2.7 | 1 |
| 1050 | Deluxe Bundle 2X | Clothing | 25.05 | 4.4 | 1 |
| 1099 | Pro Unit 3X | Home & Kitchen | 32.58 | 2.1 | 1 |
| 1148 | Ultra Tool 4X | Collectibles | 40.11 | 3.8 | 1 |
| 1197 | Essential Set 5X | Toys | 47.64 | 1.5 | 1 |
| 1246 | Classic System 6X | Beauty | 0 | 3.2 | 1 |
| 1295 | Smart Gadget 7X | Automotive | 62.7 | 4.9 | 0 |
| 1344 | Eco Kit 8X | Garden | 70.23 | 2.6 | 1 |
| 1393 | Turbo Pack 9X | Music | 77.76 | 4.3 | 0 |
| 1442 | Premium Widget 10X | Electronics | 85.29 | 2 | 1 |
transactions
Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT
| transaction_id | user_id | product_id | quantity | total_amount | transaction_date |
|---|---|---|---|---|---|
| 1067 | 197 | 1001 | 2 | 23.46 | 2026-02-02 |
| 1134 | 294 | 1050 | 3 | 36.93 | 2026-03-03 |
| 1201 | 391 | 1099 | 4 | 50.4 | 2026-04-04 |
| 1268 | 488 | 1148 | 5 | 63.87 | 2026-05-05 |
| 1335 | 585 | 1197 | 0 | 0 | 2026-06-06 |
| 1402 | 682 | 1246 | 2 | 90.81 | 2026-07-07 |
| 1469 | 779 | 1295 | 3 | 104.28 | 2026-08-08 |
| 1536 | 876 | 1344 | 4 | 117.75 | 2026-09-09 |
| 1603 | 973 | 1393 | 5 | -45 | 2026-01-10 |
| 1670 | 1070 | 1442 | 1 | 144.69 | 2026-02-11 |
Expected output
| product_id | product_name |
|---|---|
| 1491 | Basic Device 11X |
| 2030 | Deluxe Bundle 22X |
| 2569 | Pro Unit 33X |
| 3108 | Ultra Tool 44X |
| 3647 | Essential Set 55X |
Worked solution and explanation
This is a set difference wearing a catalog costume
Strip the business framing and this is products MINUS the product_ids that appear in transactions: a pure anti-join. The skill being probed is whether you can assert NON-existence, not existence. Almost everyone reaches for a join between the two tables, and that instinct is exactly the trap. A positive join can only surface products that DID sell, so inverting it in your head does not invert the SQL. The correct move is LEFT JOIN from products and keep only the rows where the transaction side failed to match. Get the direction wrong and you return the mirror image of the answer: every product that HAS sold. Get the IS NULL column wrong and you leak false positives.
Why the obvious join betrays you
- The instinct (wrong)
- INNER JOIN products to transactions returns only products that matched a sale. There is no WHERE clause you can bolt on to make an inner join emit the rows that were never there to begin with: an unmatched product simply produces zero rows. Candidates who start here end up computing the exact opposite set, the 91 products that sold.
- The anti-join (right)
- LEFT JOIN keeps every product (all 200 in the catalog) and pads unmatched ones with NULLs on the transaction side. Filtering WHERE
t.transaction_idIS NULL keeps precisely the 109 products for which no transaction row existed. That is the set difference expressed directly.
Building it
Step 1: Anchor on products with a LEFT JOIN
products goes on the left so every catalog row survives the join regardless of sales. If you write it as transactions LEFT JOIN products you are back to only ever seeing rows that have a transaction, which cannot express never-sold.
Step 2: Match on the shared key
ON p.product_id = t.product_id is the linkage. Matched products get one or more transaction rows attached; unmatched products get a single row with the entire transaction side NULL.
Step 3: Keep only the misses, on a non-null key
WHERE t.transaction_id IS NULL selects the padded rows. transaction_id is the transactions primary key, so it is never null in a real match, that is what makes the IS NULL test a reliable never-matched signal rather than a coincidence of missing data.
SELECT p.product_id, p.product_name
FROM products p
LEFT JOIN transactions t ON p.product_id = t.product_id
WHERE t.transaction_id IS NULLCommon follow-up questions
- Rewrite this as NOT EXISTS. Is the result identical, and is the plan? (Tests whether the candidate knows NOT EXISTS is the same anti-join semantics and that most optimizers produce the same anti-join plan.)
- A colleague suggests WHERE `product_id` NOT IN (SELECT `product_id` FROM transactions). Why is that dangerous here? (Tests the NOT IN null trap: 18 sales here carry a NULL `product_id`, and a single NULL in the subquery makes NOT IN return no rows at all.)
- `transactions.product_id` has only ~13,000 distinct values in 40M rows. What index makes this anti-join cheap, and why does low cardinality help? (Tests indexing intuition for a high-fan-out foreign key and index-only anti-join probes.)