Transaction Share of User Spend
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
- Asked in
- Etsy
Problem
We measure every purchase in transactions against everything its user has ever spent, yet each purchase keeps its own line instead of collapsing into one row per user. Show the transaction ID, the username from users, the amount and that share as a decimal, with usernames in alphabetical order and each user's purchases by ascending transaction ID.
Each transaction's share of the whole.
Tables
users
Columns: user_id INTEGER, username TEXT, email TEXT, signup_date TEXT, account_status TEXT, age_bucket TEXT
| user_id | username | signup_date | account_status | age_bucket | |
|---|---|---|---|---|---|
| 100 | alice | alice@example.com | 2025-02-02 | inactive | 25-34 |
| 197 | aaron42 | aaron42@example.com | 2026-03-03 | suspended | 35-44 |
| 294 | amelia | amelia@example.com | 2024-04-04 | pending_verification | 45-54 |
| 391 | arjun | arjun@example.com | 2025-05-05 | active | 55-64 |
| 488 | ava99 | ava99@example.com | 2026-06-06 | inactive | 65+ |
| 585 | andrew_k | andrew_k@example.com | 2024-07-07 | suspended | NULL |
| 682 | anika | anika@example.com | 2025-08-08 16:34:00 | pending_verification | 18-24 |
| 779 | aiden | aiden@example.com | 2026-09-09 14:53:00 | active | 25-34 |
| 876 | aria_b | aria_b@example.com | 2024-10-10 | inactive | 35-44 |
| 973 | brian | brian@example.com | 2025-11-11 | suspended | 45-54 |
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
| transaction_id | username | total_amount | spend_share |
|---|---|---|---|
| 1134 | amelia | 36.93 | 0.004101911337403033 |
| 2139 | amelia | 238.98 | 0.026544131367792496 |
| 3144 | amelia | 441.03 | 0.04898635139818196 |
| 4149 | amelia | 643.08 | 0.07142857142857142 |
| 5154 | amelia | 845.13 | 0.09387079145896088 |
Worked solution and explanation
This is a grain problem dressed up as a spend report. The numerator lives at the transaction grain and the denominator lives at the user grain, and the whole question is whether you can put both on one row without losing either. Everyone knows how to get SUM(total_amount) per user. The reflex is GROUP BY user_id, and the moment you write it, transaction_id and each row's total_amount are folded into 15 buyer rows. You cannot report a share of a purchase you just collapsed away.
Two grains on one row
Step 1: Attach the username
Inner-join transactions to users on user_id. Every buyer exists in users and user_id is unique there, so the join neither drops nor duplicates a purchase: 200 rows in, 200 rows out.
Step 2: Broadcast the user total
SUM(t.total_amount) OVER (PARTITION BY t.user_id) computes each user's all-time spend and hands that number to every one of their rows. There is no ORDER BY inside the window, so the frame is the whole partition. Add one and you silently get a running total instead.
Step 3: Divide at the row
t.total_amount * 1.0 / ... is the share. total_amount is REAL on this seed, so the * 1.0 is insurance here, not a fix. It is the habit that saves you the day the amount is stored as integer cents.
Step 4: Sort by what you return
Order by u.username, then t.transaction_id. Both are output columns, so the order can be checked from the rows alone. The leading space in ' amelia' sorts ahead of every letter, which is why that user opens the result.
SELECT
t.transaction_id,
u.username,
t.total_amount,
t.total_amount * 1.0 / SUM(t.total_amount) OVER (PARTITION BY t.user_id) AS spend_share
FROM transactions t
JOIN users u ON t.user_id = u.user_id
ORDER BY u.username, t.transaction_id- Collapse first
GROUP BY t.user_idwithSUM(t.total_amount)returns 15 rows, one per buyer. The transaction IDs and amounts are gone, and getting them back means joining the totals ontotransactionsin a second pass.- Broadcast instead
SUM(...) OVER (PARTITION BY t.user_id)returns all 200 rows, each with its own amount next to its user's total. One pass and nothing to reassemble.
Common follow-up questions
- What if a user's purchases net to exactly zero? (Division by zero: guard the denominator with `NULLIF(..., 0)` and decide what the share means then.)
- How would you show the share as a percentage rounded to two places? (Scale by 100 and wrap in `ROUND(..., 2)` without changing the window.)
- What if you wanted each purchase's share of its product category instead? (Join `products` on `product_id`, handle the purchases with a `NULL` product, and partition by `category`.)
- How would you show a cumulative share as each user's spend builds up over time? (Add `ORDER BY transaction_date` inside the window for a running numerator, keeping the full-partition total as the denominator.)