Transaction Timeline
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
- L3
Problem
Our lifetime-value model is being fed a purchase snapshot with a row per transaction, so every buyer shows up a dozen times instead of once. Rebuild it from users and transactions so it holds only customers who have bought at least once, each with their username, first and latest purchase dates and total spend, as an alphabetical roster by username.
First purchase to last. The full spending arc.
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 | first_purchase | latest_purchase | lifetime_spend |
|---|---|---|---|
| amelia | 2023-02-03 | 2026-09-07 | 9003.12 |
| aaron42 | 2022-01-02 | 2026-08-17 | 8814.54 |
| aiden | 2023-01-10 | 2026-08-25 | 9946.02 |
| alice | 2026-01-07 | 2026-09-07 | 8605.98 |
| andrew_k | 2026-02-10 | 2026-09-08 | 9491.52 |
Worked solution and explanation
This is a one-to-many collapse dressed up as a finance snapshot. Every buyer owns a dozen or so rows in transactions, the name lives in users, and the model wants exactly one row per buyer. Anyone can write MIN, MAX and SUM. What separates candidates is scoping: who belongs in the snapshot, and which key defines a customer. Reach for the reflexive LEFT JOIN and the 185 users who never bought anything show up as rows of NULLs. Group on username instead of user_id and two people who share a name get blended into one fake customer with merged dates and summed spend.
Walking to the query
Step 1: Let the join decide who is a buyer
The snapshot is about buyers, so keep only users with a matching transaction. An inner join of users to transactions on user_id does that by construction: a user with no rows on the right never appears, and you get the 15 buyers without writing a filter.
Step 2: Group on user_id, not username
The grain is one row per customer, and the customer is user_id, the key of users. username sits in the GROUP BY only so it can be selected; it is not the identity. The buyers' names happen to be unique on this data, which is exactly why the bug passes a sample check and shows up in production.
Step 3: Compute all three figures in one pass
MIN(t.transaction_date) and MAX(t.transaction_date) give the first and latest purchase, and SUM(t.total_amount) gives the spend, all from the same grouped rows. The dates are ISO strings such as '2026-02-02', so text order is date order and no conversion is needed.
Step 4: Sort the roster on the stored name
ORDER BY u.username sorts on a column the query returns, so the grader checks that your rows are sorted by it. Each buyer has a different name, so no tiebreaker is owed.
SELECT
u.username,
MIN(t.transaction_date) AS first_purchase,
MAX(t.transaction_date) AS latest_purchase,
ROUND(SUM(t.total_amount), 2) AS lifetime_spend
FROM users u
JOIN transactions t ON t.user_id = u.user_id
GROUP BY u.user_id, u.username
ORDER BY u.username;Join, group on the user_id key, compute the three figures, sort on username.
- Keep every user
users LEFT JOIN transactionsreturns all 200 users. The 185 who never bought produce rows withNULLdates and aNULLspend, so the model trains on customers with no purchase history.- Keep only buyers
users JOIN transactionsreturns only the 15 users with at least one row intransactions, which is exactly the population a lifetime-value model needs.
Common follow-up questions
- How would you add the number of days between each customer's first and latest purchase? (Date arithmetic on `MIN(t.transaction_date)` and `MAX(t.transaction_date)` inside the same grouped pass.)
- Finance now wants every user, with zero spend for those who never bought. What changes? (A `LEFT JOIN` plus `COALESCE(SUM(t.total_amount), 0)`, and a clear decision about what the date columns hold.)
- How would you add average spend per transaction? (`AVG(t.total_amount)` in the same pass, and whether refunds and voids should count in the denominator.)
- The roster should ignore stray spaces in names. How do you change it without merging customers? (Trim for display and sorting while still grouping on `user_id`.)