Customers Without Orders
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
Problem
Our signup numbers keep climbing while revenue stays flat, and we suspect many of the accounts in users have never bought anything. How many users have no purchase at all in transactions?
Customers who have never ordered.
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
| no_order_count |
|---|
| 185 |
Worked solution and explanation
Take away the business story and this is an anti-join: all accounts in users minus the accounts that appear in transactions, then counted. The phrase 'no purchase at all' does all the work. Anyone can join the two tables. What separates candidates is keeping the side that did NOT match, and proving it did not match. Only a small share of users ever bought anything. So a join that keeps matches counts buyers, which is the exact opposite answer. A NULL test on the wrong column quietly adds purchases back in as if they were non-buyers.
- users: user_id int PK, username text, email text, signup_date date, account_status text, age_bucket text
- transactions: transaction_id int PK, user_id int FK, product_id int, total_amount numeric, transaction_date date
- transactions.user_id references users.user_id
The trap that eats the answer
An inner join of users to transactions emits one row per purchase, so the accounts that never bought are gone before anything gets counted. Use the inner join only to build the positive set (who did buy). Then bring every account back with a left join and keep the rows that set never matched. The IS NULL on the right side is what performs the subtraction.
Step 1: Build the set of buyers
Inner-join users to transactions on user_id and keep DISTINCT u.user_id. A buyer has a dozen or more purchases, and DISTINCT collapses them to one row per buyer, so the buyer set really is a set.
Step 2: Keep every account with a LEFT JOIN
Left-join users to buyers. Every account survives. An account that is absent from buyers comes back with b.user_id padded to NULL.
Step 3: Test the join key for NULL
WHERE b.user_id IS NULL keeps exactly the accounts with no purchase. Test the key you joined on: it is never NULL on a real match, so a NULL there can only mean there was no match.
Step 4: Count the survivors
COUNT(*) over what is left is the single number that was asked for. Each users row is one account, so no further dedup is needed.
WITH buyers AS (
SELECT DISTINCT u.user_id
FROM users u
INNER JOIN transactions t ON u.user_id = t.user_id
)
SELECT COUNT(*) AS no_order_count
FROM users u
LEFT JOIN buyers b ON u.user_id = b.user_id
WHERE b.user_id IS NULLThe CTE builds the buyer set with an inner join; the outer LEFT JOIN plus IS NULL keeps only the accounts that set never matched.
- Inner join only (wrong)
- Counting
usersjoined totransactionstallies the purchase rows of accounts that DID buy. No unmatched row is left to keep, because the join has already dropped them. - Buyer set, then subtract (right)
- The CTE names the buyers, the
LEFT JOINbrings every account back, andIS NULLkeeps only the accounts the buyer set never matched. That count is the answer.
Common follow-up questions
- How would you return the count of non-buyers per `account_status`? (Tests adding a grouping on top of the anti-join without breaking the `IS NULL` filter.)
- If a user has many transactions, does your count change? (Checks that the unmatched side never fans out, so `COUNT(*)` counts accounts.)
- What breaks if `transactions.user_id` could be `NULL` and you wrote `NOT IN`? (One `NULL` in the subquery makes `NOT IN` return no rows at all.)