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

users: 10 of 200 rows
user_idusernameemailsignup_dateaccount_statusage_bucket
100alicealice@example.com2025-02-02inactive25-34
197aaron42aaron42@example.com2026-03-03suspended35-44
294 ameliaamelia@example.com2024-04-04pending_verification45-54
391arjun arjun@example.com2025-05-05active55-64
488ava99ava99@example.com2026-06-06inactive65+
585andrew_kandrew_k@example.com2024-07-07suspendedNULL
682anikaanika@example.com2025-08-08 16:34:00pending_verification18-24
779aidenaiden@example.com2026-09-09 14:53:00active25-34
876aria_baria_b@example.com2024-10-10inactive35-44
973brianbrian@example.com2025-11-11suspended45-54

page_views

Columns: view_id INTEGER, page_url TEXT, user_id INTEGER, referrer TEXT, dur_ms INTEGER, device TEXT, viewed_at TEXT

page_views: 10 of 200 rows
view_idpage_urluser_idreferrerdur_msdeviceviewed_at
5053/products1555amazon.com637desktop2026-09-02 01:09:00
5106/checkout1652twitter.com774tablet2026-09-03 02:18:00
5159/profile1749github.com911Mobile2026-09-04 03:27:00
5212/search1846NULL1048DESKTOP2026-09-05 04:36:00
5265/about1943reddit.com1185mobile2026-09-06 05:45:00
5318/blog/intro1555NULLNULLdesktop2026-09-07 06:54:00
5371/pricing1652bing.com1459tablet2026-09-08 07:03:00
5424/docs/api1749news.ycombinator.com1596Mobile2026-09-09 08:12:00
5477/settings1846NULL1733DESKTOP2026-09-10 09:21:00
5530/Home1943bing.com1870mobile2026-09-11 10:30:00

transactions

Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT

transactions: 10 of 200 rows
transaction_iduser_idproduct_idquantitytotal_amounttransaction_date
10671971001223.462026-02-02
11342941050336.932026-03-03
12013911099450.42026-04-04
12684881148563.872026-05-05
13355851197002026-06-06
14026821246290.812026-07-07
146977912953104.282026-08-08
153687613444117.752026-09-09
160397313935-452026-01-10
1670107014421144.692026-02-11

Expected output

First 4 of 4 rows
user_idusernameemailtotal_views
1749chloechloe@example.com12
1943cyruscyrus@example.com12
1846cameroncameron@example.com11
1652carloscarlos@example.com11

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

WINDOW SHOPPERS WITH NO PURCHASES
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 the ON clause 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