Tiers of Want
A hard SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- hard
- Seniority
- L5
- Asked in
- HealthTap
Problem
A retail marketplace wants to see how its shoppers stack up inside each product category, scoring every shopper on their average basket size (total spend divided by number of purchases) in that category. Sort each shopper into a spend tier: High above $500, Medium from $200 up to $500, and Low below that. For each category and tier, report how many shoppers land there along with their combined transactions, combined sales, and the average basket size across them.
In each category, some carts weigh more than others. Sort the shoppers by what they spend.
Tables
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 |
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 |
Expected output
| category | segment | unique_users | total_transactions | total_sales | avg_basket_size |
|---|---|---|---|---|---|
| Automotive | High | 3 | 17 | 11875.26 | 665.5300000000001 |
| Beauty | High | 3 | 17 | 11915.67 | 701.4499999999999 |
| Books | High | 3 | 19 | 12501.39 | 681.9933333333333 |
| Clothing | High | 3 | 19 | 12487.92 | 695.4633333333333 |
| Collectibles | Low | 2 | 4 | 524.88 | 131.22 |
Worked solution and explanation
What this problem really is
This is a two-level rollup dressed up as customer segmentation. The tier belongs to a shopper inside a category, and that shopper's average basket only exists after you collapse transactions to one row per user_id and category. Anyone can write the CASE. What separates candidates is where it runs. Put it on raw total_amount and you tier purchases instead of people, and COUNT(*) then reports transactions where the question wants shoppers. The quieter trap is the last column. SUM(total_sales) / SUM(txn_count) at the tier grain looks like an average basket, but it weights every shopper by how often they buy. On this seed Automotive's High tier is 665.53 as a mean of the shoppers' baskets and 698.54 as a pooled ratio.
Break down the requirements
Step 1: Compute basket size per shopper per category
Join transactions to products for the category, group by user_id, category, and compute SUM(total_amount) * 1.0 / COUNT(*) as basket_size. Every row is one purchase, so COUNT(*) is the right denominator, not SUM(quantity).
Step 2: Label segments on the shopper row
Apply the CASE to that per-shopper average: > 500 is 'High', >= 200 is 'Medium', everything else 'Low'. Because the WHEN clauses are tested in order, the High check must come first.
Step 3: Re-aggregate per category per segment
Group the shopper rows by category, segment. COUNT(*) now counts shoppers, SUM(txn_count) and SUM(total_sales) add up their activity, and AVG(basket_size) gives each shopper one equal vote.
The solution
WITH user_baskets AS (
SELECT
t.user_id,
p.category,
SUM(t.total_amount) AS total_sales,
COUNT(*) AS txn_count,
SUM(t.total_amount) * 1.0 / COUNT(*) AS basket_size,
CASE
WHEN SUM(t.total_amount) * 1.0 / COUNT(*) > 500 THEN 'High'
WHEN SUM(t.total_amount) * 1.0 / COUNT(*) >= 200 THEN 'Medium'
ELSE 'Low'
END AS segment
FROM transactions t
JOIN products p ON t.product_id = p.product_id
GROUP BY t.user_id, p.category
)
SELECT
category,
segment,
COUNT(*) AS unique_users,
SUM(txn_count) AS total_transactions,
SUM(total_sales) AS total_sales,
AVG(basket_size) AS avg_basket_size
FROM user_baskets
GROUP BY category, segment- Pooled ratio
SUM(total_sales) / SUM(txn_count)at the tier grain. A shopper with eight purchases pulls the figure twice as hard as one with four, so frequent buyers decide the number. Automotive High reads 698.54.- Mean of shopper baskets
AVG(basket_size)over theuser_basketsrows. Each shopper contributes their own average once, which is what 'the average basket size across them' asks for. Automotive High reads 665.53.
Common follow-up questions
- What if a shopper has transactions in multiple categories? (They appear in multiple `user_baskets` rows, one per category, and can sit in a different tier in each. This is correct per the requirements.)
- How would you classify a shopper with a basket size of exactly $200 or $500? (The `CASE` uses `>= 200` for Medium and `> 500` for High, so both edges land in Medium.)
- What if you needed to add a time dimension for monthly segmentation? (Add a month bucket of `transaction_date` to the first grouping, so the tier is assigned per user, category and month.)
- How would you optimize this for a 10x larger dataset? (Materialize `user_baskets` as a summary table updated incrementally instead of rescanning the full transaction log each run.)