The Ones Who Clicked
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
- Asked in
- Microsoft
Problem
A search only counts as a success when the user clicked one of its results, and on our shopping marketplace everyone in users who signed up in the same calendar year forms one cohort. List the cohorts from earliest to most recent with how many search_queries their users ran, how many of those succeeded, and the success rate.
Every signup cohort runs its searches. Find the years that turned queries into clicks.
Tables
search_queries
Columns: query_id INTEGER, user_id INTEGER, search_term TEXT, results_count INTEGER, clicked_result INTEGER, query_time TEXT
| query_id | user_id | search_term | results_count | clicked_result | query_time |
|---|---|---|---|---|---|
| 2071 | 100 | laptop deals | 17 | 2 | 2026-02-02 01:11:00 |
| 2142 | 197 | phone case iphone 15 | 34 | 3 | 2026-03-03 02:22:00 |
| 2213 | 294 | usb-c cable 6ft | 51 | NULL | 2026-04-04 03:33:00 |
| 2284 | NULL | gaming mouse | 68 | 5 | 2026-05-05 04:44:00 |
| 2355 | 488 | mechanical keyboard | 85 | 6 | 2026-06-06 05:55:00 |
| 2426 | 585 | 4k monitor | 102 | NULL | 2026-07-07 06:06:00 |
| 2497 | 682 | standing desk | 119 | 8 | 2026-08-08 07:17:00 |
| 2568 | NULL | webcam 1080p | 136 | 9 | 2026-09-09 08:28:00 |
| 2639 | 876 | bluetooth speaker | 153 | NULL | 2026-01-10 09:39:00 |
| 2710 | 973 | smart watch | 170 | 1 | 2026-02-11 10:50:00 |
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 |
Expected output
| signup_cohort | total_searches | successful_searches | success_rate |
|---|---|---|---|
| 2024 | 50 | 0 | 0 |
| 2025 | 50 | 48 | 0.96 |
| 2026 | 50 | 46 | 0.92 |
Worked solution and explanation
What this really tests
Strip the search-quality story and this is a per-cohort click-through rate over a join. Anyone can join searches to users and divide. What separates candidates is three quiet decisions. First, the cohort is the year sliced out of a text signup_date, and some rows carry a time part, so keying on the raw value gives you a bucket per signup day. Second, a NULL clicked_result is a search nobody clicked, and it stays in the denominator. Third, successful over total is integer division unless you cast. Miss the first and you return dozens of rows; miss the last and 2025's 0.96 reads 0.
- search_queries: query_id int PK, user_id int FK, clicked_result int, query_time timestamp
- users: user_id int PK, signup_date date
- search_queries.user_id references users.user_id
Building it
Step 1: Bucket each user by signup year
A cohort is the calendar year of signup_date, stored as text, sometimes with a time part ('2025-08-08 16:34:00'). strftime('%Y', u.signup_date) reads the year off either form and returns a four-character label. Do it once in the cohort CTE so the join downstream stays clean.
Step 2: Attach searches to cohorts with an inner join
Join search_queries to cohort on user_id. The inner join is deliberate: searches whose user_id is NULL, the orphan rows in the sample, fall out, because a search with no owner belongs to no cohort. An outer join would carry them into a phantom NULL bucket.
Step 3: Aggregate in one pass, then cast before dividing
COUNT(*) gives total searches per cohort. SUM(CASE WHEN sq.clicked_result IS NOT NULL THEN 1 ELSE 0 END) counts the successful ones in the same scan. Cast that sum to REAL before dividing by the total, or the engine does integer division and 48 over 50 comes back as 0.
The solution
WITH cohort AS (
SELECT u.user_id, strftime('%Y', u.signup_date) AS signup_cohort
FROM users u
)
SELECT
c.signup_cohort,
COUNT(*) AS total_searches,
SUM(CASE WHEN sq.clicked_result IS NOT NULL THEN 1 ELSE 0 END) AS successful_searches,
CAST(SUM(CASE WHEN sq.clicked_result IS NOT NULL THEN 1 ELSE 0 END) AS REAL) / COUNT(*) AS success_rate
FROM search_queries sq
JOIN cohort c ON sq.user_id = c.user_id
GROUP BY c.signup_cohort
ORDER BY c.signup_cohort- Naive
WHERE clicked_result IS NOT NULL, thenCOUNT(*)as successful, a second query for totals, andsuccessful / totalas-is. The filter drops failed searches from the denominator and integer division flattens the rate.- Correct
- One pass:
COUNT(*)for total,SUM(CASE WHEN clicked_result IS NOT NULL ...)for successful, cast toREALbefore dividing. Denominator intact, rate fractional.
Common follow-up questions
- How would you also break each cohort down by `age_bucket` without running the query a second time? (Tests adding a dimension to the same pass, and what a NULL `age_bucket` does to the grouping.)
- `search_queries` holds 80M rows partitioned by `query_time`. If you only wanted the last 30 days of searches, how would you keep the scan from touching every partition? (Tests partition pruning against the `query_time` partition key.)
- How would you flag the cohorts whose `success_rate` falls below the rate across all attributed searches combined? (Tests comparing a per-group metric against an overall aggregate, and whether a mean of rates is the right baseline.)