What the Shelf Never Sold
A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- medium
- Seniority
- L3
- Asked in
- General Assembly
Problem
List every product in the catalog with its revenue, the sum of what buyers were charged across all of its transactions, rounded to two decimals, and have a product that never sold read zero. The merchandising team reads the report walking the catalog from the lowest product number to the highest.
Some products never moved a single unit. They still belong on the report.
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 | total_revenue |
|---|---|---|
| 1001 | Basic Device 1X | 272.43 |
| 1050 | Deluxe Bundle 2X | 312.84 |
| 1099 | Pro Unit 3X | 100.8 |
| 1148 | Ultra Tool 4X | 127.74 |
| 1197 | Essential Set 5X | 77.34 |
Worked solution and explanation
What this really is
This is an outer join wearing a finance hat. The whole problem comes down to which table defines the universe. Anyone can add up sales. The candidates who pass notice that the report's spine is the catalog, not the sales. On this data only 91 product ids ever appear in transactions, against 200 rows in products, so the answer owes a row to more than a hundred products nobody bought. Anchor on transactions and those rows vanish without a trace. Keep them with an outer join but trust a bare SUM, and they come back NULL instead of 0. Both mistakes run clean and are quietly wrong.
How to get there
Step 1: Make products the driver
Start from products and attach transactions as the optional side with a LEFT JOIN on product_id. That one decision keeps a product with no sale in the report. Flip it to an INNER JOIN and the 200-row report shrinks to the products that sold, with nothing in the output warning you. Sales whose product_id is empty attach to no catalog row, so they fall away, which is what a per-product report wants.
Step 2: Sum the right number
Revenue here is the money actually collected, so sum the total_amount recorded on each sale. The catalog carries price and each sale carries quantity. Multiplying them is the trap: it looks like revenue but ignores what was actually charged. The first sample rows prove it: product 1001 is priced at 17.52, yet a 2-unit sale of it shows 23.46, not 35.04. The voided sale at 0.0 and the -45.0 refund belong in the sum too, because they are what was charged.
Step 3: Turn the empty sum into zero, in cents
For an unsold product, the join hands the group one row whose total_amount is NULL. SUM over nothing but NULL returns NULL, not zero. Wrap it in COALESCE(..., 0) so every product reports a number. Then apply ROUND(..., 2): total_amount is REAL, and adding floats leaves noise past the cents that the grade would see.
Step 4: Walk the catalog in product order
The report is read from the lowest product number up, so it ends with ORDER BY p.product_id. Product ids are unique in products, so that order is total and needs no tiebreaker.
The solution
SELECT p.product_id,
p.product_name,
ROUND(COALESCE(SUM(t.total_amount), 0), 2) AS total_revenue
FROM products p
LEFT JOIN transactions t ON p.product_id = t.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_id- INNER JOIN (wrong)
- Keeps only products with at least one matching sale. Every product nobody bought disappears, and the report silently shrinks from 200 rows to the 91 that sold. No error, no warning, just missing products.
- LEFT JOIN (correct)
- Keeps all 200 products. The unmatched ones carry a
NULLsum, whichCOALESCEturns into 0, so every row reads a real number.
Common follow-up questions
- Add a column for the number of units sold per product. How do you keep an unsold product reading 0 there too? (Tests whether the candidate separates revenue from units and reuses the same outer join with a default on `SUM(t.quantity)`.)
- If you used `COUNT(*)` instead of counting a `transactions` column, what would an unsold product report, and why? (Probes whether the candidate knows that `COUNT(*)` counts the preserved left row even with no match, while `COUNT(t.transaction_id)` does not.)
- Now return only products whose total revenue is below 100, but still include the ones that never sold. Where does that filter go? (Pushes toward filtering after the aggregate, on the defaulted value, without re-dropping the zero-revenue products.)