The Biggest Fish
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
- mid
- Asked in
- Barclays
Problem
We're planning loyalty outreach around whoever spends the most in each product category, where every purchase in transactions counts toward a customer's total in the category its product carries in products. Going through the categories alphabetically, return each one's top spender with that total; when two customers tie for the top, list the lower user_id first.
In every aisle, one customer outspends the rest. Find them.
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 | user_id | total_spent |
|---|---|---|
| Automotive | 779 | 5683.44 |
| Beauty | 1167 | 4585.86 |
| Books | 197 | 5262.39 |
| Clothing | 294 | 5383.62 |
| Collectibles | 1458 | 397.14 |
Worked solution and explanation
What this problem is really testing
Strip the loyalty framing and this is the classic top-1-per-group. The skill being probed is whether you keep two concerns separate: rolling spend up to the (category, customer) grain, and then picking the winner within each category. Anyone can write the SUM. The tell is which window function you reach for, because RANK and ROW_NUMBER give different answers the moment two customers tie at the top, and only one of them is correct here.
- transactions: transaction_id int PK, user_id int, product_id int FK, total_amount numeric
- products: product_id int PK, category text
- transactions references products
Break down the requirements
Step 1: Attach a category to every transaction
The category lives in products, not transactions, so join on product_id first. This is an inner join on purpose: a transaction whose product has no category row cannot belong to any category leaderboard, so dropping it is correct, not a bug.
Step 2: Roll up to (category, customer) spend
GROUP BY category, user_id and SUM(total_amount). This collapses many transactions per customer into one total per category, which is the grain the question actually cares about. Do this in a CTE so the ranking step reads cleanly.
Step 3: Rank within each category and keep the top
RANK() OVER (PARTITION BY category ORDER BY total_spent DESC) numbers customers per category, biggest spender first. Filter spend_rank = 1. RANK gives every tied top spender the value 1, so a genuine tie surfaces both customers instead of silently discarding one.
The solution
WITH category_spend AS (
SELECT p.category AS category,
t.user_id AS user_id,
SUM(t.total_amount) AS total_spent
FROM transactions t
JOIN products p ON t.product_id = p.product_id
GROUP BY p.category, t.user_id
),
ranked AS (
SELECT category,
user_id,
total_spent,
RANK() OVER (PARTITION BY category ORDER BY total_spent DESC) AS spend_rank
FROM category_spend
)
SELECT category, user_id, total_spent
FROM ranked
WHERE spend_rank = 1
ORDER BY category, user_id- RANK() (correct)
- Ties both get rank 1, so a category with two customers at the same top total returns both rows. That matches 'the customer who spends the most', which is a set, not a guarantee of one row.
- `ROW_NUMBER`() (subtly wrong)
ROW_NUMBERassigns 1 and 2 to tied customers arbitrarily, so filtering to 1 keeps whichever row the engine happened to order first and drops a real co-leader. It looks right on clean fixtures and hides the bug until a tie appears in production.
Common follow-up questions
- Now return the top three spenders per category instead of just the leader. (Tests whether they see the window CTE generalizes: change the filter to `spend_rank` <= 3, and whether they switch to `DENSE_RANK` to avoid gaps skipping the third slot.)
- Scope this to the last full year of activity. Where does the date predicate go? (Probes partition pruning: the `transaction_date` filter belongs before the aggregate so it prunes the fact scan, not after the window.)
- What if a category has no transactions at all? Should it appear? (Surfaces inner-join semantics and whether they would switch to a products-driven LEFT JOIN to show empty categories with a NULL top spender.)