Spending Range
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
- L5
Problem
We're modeling spending variability for our regular customers, the ones with more than a dozen purchases in the transactions table, and we measure it as the gap between a customer's smallest and largest transaction amount taken as a percentage of that largest amount. List each regular's username from users alongside their smallest and largest amounts and that percentage rounded to one decimal place, most variable first.
Between the smallest purchase and the biggest lies the story.
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
| username | low_amount | high_amount | spread_pct |
|---|---|---|---|
| brian | -45 | 1343.52 | 103.3 |
| andrew_k | 0 | 1289.64 | 100 |
| aaron42 | 23.46 | 1235.76 | 98.1 |
| amelia | 36.93 | 1249.23 | 97 |
| arjun | 50.4 | 1262.7 | 96 |
Worked solution and explanation
What this is really testing
Strip away the spending-variability framing and this is a per-user cohort guard bolted onto a per-user min and max. Anyone can collapse transactions to a low and a high per user; what separates candidates is where the 'more than a dozen purchases' test lives and what it counts. It is a property of a user's whole transaction set, so it has to be evaluated per user, and it counts rows, repeats included. On this seed every buyer's history is six or seven purchases, each recorded twice, so a candidate who counts unique products tops out at seven and returns nothing at all, and one who drops the guard returns all fifteen buyers instead of the ten regulars.
Building it stage by stage
Step 1: Collapse each user to a low and a high
Inner-join users to transactions on user_id and group by the user, emitting MIN(total_amount) as low_amount and MAX(total_amount) as high_amount. One hash-aggregate pass over the join gives every user's range. The inner join drops users who never bought, which is correct: they have no range and could never clear the guard.
Step 2: Guard with a correlated count
In the outer query, keep a row only when (SELECT COUNT(*) FROM transactions AS t WHERE t.user_id = ur.user_id) exceeds twelve. Because it references ur.user_id from the grouped row, the count is scoped to exactly that user, and because it counts rows, a repeated purchase counts twice.
Step 3: Turn the gap into a share of the largest purchase
Divide high_amount - low_amount by high_amount, scale by 100.0 and round to one decimal as spread_pct. The raw gap cannot do this job on this seed: eight of the ten regulars share the same gap of 1212.3, while the share of the largest purchase separates every one of them. Order by spread_pct descending so the most variable customers read first.
The solution
WITH user_range AS (
SELECT
u.user_id,
u.username,
MIN(t.total_amount) AS low_amount,
MAX(t.total_amount) AS high_amount
FROM users AS u
JOIN transactions AS t
ON u.user_id = t.user_id
GROUP BY
u.user_id,
u.username
)
SELECT
username,
low_amount,
high_amount,
ROUND(100.0 * (high_amount - low_amount) / high_amount, 1) AS spread_pct
FROM user_range AS ur
WHERE
(
SELECT
COUNT(*)
FROM transactions AS t
WHERE
t.user_id = ur.user_id
) > 12
ORDER BY
spread_pct DESC- Correlated count in the outer filter
- The guard reads as a self-contained per-user question and shows the correlated scalar subquery directly. It costs one indexed probe of
transactionsper candidate user. - Count folded into the grouped pass
- Adding
COUNT(*)to theuser_rangeCTE and filtering withHAVING COUNT(*) > 12rides along in the same grouped pass, with no second probe. It returns the same ten rows and is the form you ship at scale.
Common follow-up questions
- If a transaction could be recorded twice by mistake, how would you keep a duplicate from pushing a customer over the purchase cutoff? (Tests whether a row-level dedup before grouping changes both the count guard and the extremes.)
- How would you also return the date of each regular's largest transaction? (Tests reaching for a window function when per-row context must survive next to the aggregate.)
- What would you do if a customer's largest amount could be zero or negative? (Tests guarding the division with `NULLIF` or a `CASE` on the denominator.)