The Phantom Readers
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
Which users have never made a purchase, with no row anywhere in transactions, yet logged more than 10 page_views in the past 60 days? The promotion list shows each of those window shoppers with their account details and total view count, highest count first and then alphabetically by username.
They read everything. They bought nothing.
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 |
page_views
Columns: view_id INTEGER, page_url TEXT, user_id INTEGER, referrer TEXT, dur_ms INTEGER, device TEXT, viewed_at TEXT
| view_id | page_url | user_id | referrer | dur_ms | device | viewed_at |
|---|---|---|---|---|---|---|
| 5053 | /products | 1555 | amazon.com | 637 | desktop | 2026-09-02 01:09:00 |
| 5106 | /checkout | 1652 | twitter.com | 774 | tablet | 2026-09-03 02:18:00 |
| 5159 | /profile | 1749 | github.com | 911 | Mobile | 2026-09-04 03:27:00 |
| 5212 | /search | 1846 | NULL | 1048 | DESKTOP | 2026-09-05 04:36:00 |
| 5265 | /about | 1943 | reddit.com | 1185 | mobile | 2026-09-06 05:45:00 |
| 5318 | /blog/intro | 1555 | NULL | NULL | desktop | 2026-09-07 06:54:00 |
| 5371 | /pricing | 1652 | bing.com | 1459 | tablet | 2026-09-08 07:03:00 |
| 5424 | /docs/api | 1749 | news.ycombinator.com | 1596 | Mobile | 2026-09-09 08:12:00 |
| 5477 | /settings | 1846 | NULL | 1733 | DESKTOP | 2026-09-10 09:21:00 |
| 5530 | /Home | 1943 | bing.com | 1870 | mobile | 2026-09-11 10:30:00 |
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
| user_id | username | total_views | |
|---|---|---|---|
| 1749 | chloe | chloe@example.com | 12 |
| 1943 | cyrus | cyrus@example.com | 12 |
| 1846 | cameron | cameron@example.com | 11 |
| 1652 | carlos | carlos@example.com | 11 |
Worked solution and explanation
What this is really asking
This is an anti-join with a time window on only one side, dressed up as a promotion list. Anyone can count recent page_views. What separates candidates is keeping the two scopes apart: the 60-day window belongs to the views, and 'never made a purchase' is lifetime, tested as the absence of any transactions row. Window the purchases too and every lapsed buyer lands in the promotion. Test the threshold before the count exists and the user sitting at exactly 10 views slips through.
Break down the requirements
Step 1: Keep only users with no transactions row
LEFT JOIN transactions on user_id and keep the rows where p.transaction_id IS NULL. A user with even one purchase, from any year, gets a matched row and drops out. No window touches this side: the absence test runs over the whole table.
Step 2: Restrict views to the last 60 days
av.viewed_at >= date('now', '-60 days') sits in WHERE, so COUNT(*) sees only recent activity. A never-buyer has no matching transactions row, so the left join cannot repeat their view rows, and COUNT(*) is a true view count.
Step 3: Apply the threshold to the per-user count
GROUP BY the identity columns, then apply HAVING COUNT(*) > 10. On this data, one never-buyer has exactly 10 recent views. The threshold is what drops them, and it can only run once the count exists, which is why it lives in HAVING and not in WHERE.
Step 4: Sort by activity, then by name
ORDER BY total_views DESC, username lists the heaviest browsers first and settles equal counts alphabetically. Both keys are returned columns, so the order is fully visible in the result.
The solution
SELECT
u.user_id,
u.username,
u.email,
COUNT(*) AS total_views
FROM users u
INNER JOIN page_views av ON u.user_id = av.user_id
LEFT JOIN transactions p ON u.user_id = p.user_id
WHERE av.viewed_at >= date('now', '-60 days')
AND p.transaction_id IS NULL
GROUP BY u.user_id, u.username, u.email
HAVING COUNT(*) > 10
ORDER BY total_views DESC, username;- Window on views
av.viewed_at >= date('now', '-60 days')scopes only the browsing. Anyone with a purchase in any year is excluded.- Window on transactions
p.transaction_date >= date('now', '-60 days')in theONclause makes the absence test cover recent purchases only. A buyer whose last order was last year now counts as never having bought and gets promoted.
Common follow-up questions
Common follow-up questions
- How would you rewrite the exclusion with `NOT EXISTS`, and when is it faster? (`NOT EXISTS` stops at the first matching purchase per user; the left join builds every match before filtering.)
- What if growth wants more than 10 different pages, not more than 10 views? (Swap `COUNT(*)` for `COUNT(DISTINCT av.page_url)` in the `SELECT` and the `HAVING`, and decide whether '/Home' and '/home' are one page.)
- What changes if 'never made a purchase' means none in the last 90 days? (Move a `p.transaction_date` window into the `ON` clause and keep `IS NULL` in `WHERE`.)