Above Category Average
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
- L5
Problem
Every row in transactions is one sale of a product listed in products, and each product belongs to exactly one category. Find the products whose average sale amount beats the average over every sale in their category, with both averages shown side by side.
The category average is one thing. These beat it.
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, product_id INTEGER, total_amount REAL, user_id INTEGER, quantity INTEGER, 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 | product_avg | category_avg |
|---|---|---|
| Ultra Tool 14X | 198.57 | 131.22 |
| Turbo Pack 49X | 670.02 | 660.23 |
| Basic Device 51X | 696.96 | 657.967894736842 |
| Deluxe Bundle 52X | 710.43 | 657.2589473684211 |
| Pro Unit 53X | 723.9 | 679 |
Worked solution and explanation
What the problem really is
This is a two-grain comparison dressed up as a merchandising report: every product is judged against a bar computed at a coarser grain over the same sales. Anyone can average one product's sales. What separates candidates is where the category bar comes from. It has to pool every sale in the category, not average the product averages already in hand. Reuse the product-level numbers and a product with one sale counts as much as a product with three, the bar moves, and products land on the wrong side of it.
Break down the requirements
Step 1: Compute each product's mean transaction amount
First CTE: join products to transactions on product_id, GROUP BY product_id, product_name, category, and compute AVG(total_amount). Products with no sales never appear, and sales whose product_id is NULL fall out of the join.
Step 2: Compute each category's pooled mean
Second CTE: join the same two tables, GROUP BY category, and compute AVG(total_amount). That is SUM(total_amount) / COUNT(*) across every sale in the category, so a product weighs in by how many times it sold. It is not an average of per-product averages.
Step 3: Join the two CTEs on category
Each product maps to exactly one category, so each product row picks up exactly one category average. The result stays at product grain with the pooled bar attached.
Step 4: Keep strictly-above products
WHERE product_avg > cat_avg. Strictly greater, because matching the category is not beating it. Return product_name, product_avg, and cat_avg renamed to category_avg.
The solution
WITH product_avg AS (
SELECT p.product_id,
p.product_name,
p.category,
AVG(t.total_amount) AS product_avg
FROM products p
JOIN transactions t ON t.product_id = p.product_id
GROUP BY p.product_id, p.product_name, p.category
),
category_avg AS (
SELECT p.category,
AVG(t.total_amount) AS cat_avg
FROM products p
JOIN transactions t ON t.product_id = p.product_id
GROUP BY p.category
)
SELECT pa.product_name,
pa.product_avg,
ca.cat_avg AS category_avg
FROM product_avg pa
JOIN category_avg ca ON ca.category = pa.category
WHERE pa.product_avg > ca.cat_avgCommon follow-up questions
- Rewrite this in a single pass using a window function. (Group by product with `SUM(total_amount)` and `COUNT(*)`, then compute `SUM(SUM(total_amount)) OVER (PARTITION BY category) * 1.0 / SUM(COUNT(*)) OVER (PARTITION BY category)` as the pooled bar and filter where the product average is greater. This reads `transactions` once.)
- How would the answer change if "category average" meant an average of per-product averages? (Aggregate to products first, then average those. Every product weighs the same, so the bar drifts toward low-volume products.)
- How would you include products with no transactions, showing NULL for their average? (`LEFT JOIN transactions` in the per-product CTE. Their `product_avg` is NULL, which fails the strict `>` and drops out of this filter, so list them in a separate output if they must be shown.)
- How would you sort products by how far above their category average they sit? (Add `(product_avg - cat_avg) AS lift` and `ORDER BY lift DESC`, or report `(product_avg / cat_avg - 1)` as a percentage lift.)