Product Transaction Counts
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
- NVIDIA
Problem
The inventory team needs to see sales velocity per product. Show each product's name and how many transactions reference it, ordered by product ID. Only include products that have been purchased at least once.
Show how many transactions each product has, sorted by product ID.
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
This is a semi-join wearing an inventory report's clothes. Anyone can count rows per product; what separates candidates is two quieter decisions. First, the join type: the inner JOIN itself is the 'purchased at least once' filter, so reaching for a LEFT JOIN to 'show every product' quietly breaks the brief. Second, the grain: the count belongs to product_id, not to product_name, because a name is a label, not an identity. Get the join wrong and the report grows from 91 rows to all 200 catalog products, with every never-sold item showing a count of 1.
Walking to the answer
Step 1: Let the join do the filtering
transactions holds one row per sale and products holds the name, so the two must meet on product_id. An inner JOIN keeps only products with at least one matching transaction, which is exactly the brief. The 109 catalog products nobody bought never reach the count, and neither do the 18 transactions whose product_id is NULL, because NULL matches nothing.
Step 2: Count at the product's grain
GROUP BY p.product_id, p.product_name makes the identifier the group key and carries the name along for display. COUNT(*) then counts joined rows, and each joined row is one transaction, so a repeat buy by the same user counts again. That is what velocity means; COUNT(DISTINCT t.user_id) would measure reach instead.
Step 3: Sort by a key you do not show
ORDER BY p.product_id sorts on a column the result never displays. That is legal because product_id is in the GROUP BY, and it is the catalog order the team asked for. Sorting by product_name would give alphabetical order, which is a different list.
SELECT p.product_name, COUNT(*) AS transaction_count
FROM products p
JOIN transactions t ON p.product_id = t.product_id
GROUP BY p.product_id, p.product_name
ORDER BY p.product_id- Outer join to be safe
products LEFT JOIN transactionskeeps all 200 catalog rows. An unsold product arrives with a NULL-padded transaction side, andCOUNT(*)counts that padding row as 1, so a product nobody bought reports one sale.- Inner join on purpose
products JOIN transactionskeeps only the 91 products that have a matching sale. Every surviving row is a real transaction, soCOUNT(*)is an honest tally and the 'at least once' rule needs no extra clause.
Common follow-up questions
- Velocity is usually a rate. How would you report transactions per product over the last 30 days instead of all time? (Tests adding a window on `transaction_date` without disturbing the join or the grain.)
- Transaction 1335 is a voided sale with `quantity` 0, and 1603 is a refund with a negative `total_amount`. Should either count toward velocity, and how would you exclude them? (Tests reading the data instead of trusting a raw row count.)
- The catalog team now wants never-sold products listed too, with a count of 0. What changes? (Tests switching to an outer join and counting `t.transaction_id` instead of `*`.)