Workers Earning Above Department Average
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
- L4
Problem
Inner-join products to transactions on product_id. Per (product_id, product_name), count transactions. Return product_name and transaction_count. Sort by product_id ascending.
Earning above the department average.
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_name | transaction_count |
|---|---|
| Basic Device 1X | 3 |
| Deluxe Bundle 2X | 3 |
| Pro Unit 3X | 2 |
| Ultra Tool 4X | 2 |
| Classic System 16X | 1 |
Worked solution and explanation
The label is not the key
This is a count per entity dressed up as a count per name. The join and the COUNT(*) are the easy part. The skill being probed is whether you group on the product's identity (product_id) or on the text you happen to display (product_name). Group by the name and any two products that share one collapse into a single row with the summed transaction_count. You get fewer than 91 rows, inflated counts on the collided names, and nobody notices because every row still looks plausible.
The sample data hides this. Each product has exactly one transaction, so the counts are all 1 and a merged row would just read 2. A wrong query can look right if you only skim the output.
Step 1: Inner-join products to transactions
Join on product_id. The statement says inner, and that is the right call: a product nobody bought has no transaction to count, so it drops out instead of appearing with a zero. A LEFT JOIN with COUNT(*) would show those products with a count of 1, which is a lie.
Step 2: Count rows with COUNT(*)
The question is how many transactions each product has, so count transaction rows. COUNT(DISTINCT user_id) answers a different question: how many buyers. A customer who bought the same product twice would be counted once.
Step 3: Group by p.product_id, p.product_name
The key goes first, and the name rides along so it can be selected. Because product_id is unique, the name is functionally determined by it, so adding the name changes nothing about the groups. It just makes the projection legal in every engine.
Step 4: Sort by p.product_id even though you do not select it
The statement asks for product_id order, but the output shows only the name. Sorting by product_name or by transaction_count is the easy slip. Sorting on a column outside the SELECT list works only because it is in the GROUP BY.
SELECT p.product_name, COUNT(*) AS transaction_count FROM products p INNER JOIN transactions t ON p.product_id = t.product_id GROUP BY p.product_id, p.product_name ORDER BY p.product_id ASCGrouped on the key, labeled with the name, sorted on the key.
SELECT p.product_name, COUNT(*) AS transaction_count FROM products p INNER JOIN transactions t ON p.product_id = t.product_id GROUP BY p.product_name ORDER BY p.product_id ASCGroups by the display label. Wrong, and often an error.
- Group by the name
- Products that share a
product_namemerge into one row with a summed count.ORDER BY p.product_idis ill-defined because a merged group has several ids, so strict engines reject it and lenient ones pick an arbitrary id. - Group by the key and the name
- Each
product_idis its own group, so same-named products stay separate rows. The sort key is a grouping column, so the order is deterministic and you get every product that sold.
Common follow-up questions
- How would the result change if you wanted unsold products listed with a count of 0? (Tests `LEFT JOIN` plus `COUNT(t.transaction_id)` instead of `COUNT(*)`, since `COUNT(*)` would count the null-extended row as 1.)
- Two products share a `product_name`. How would you make the output readable to a human without merging them? (Tests keeping `product_id` in the `SELECT` list, or disambiguating with `category`, instead of grouping on the label.)
- How would you return only the products with more than five transactions? (Tests `HAVING COUNT(*) > 5` versus filtering in `WHERE`, which runs before the aggregate exists.)