Unclicked Searches by Campaign
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
- Asked in
- Apple
Problem
Our per-campaign search totals run higher than the search log itself, because a user shown the same campaign several times has their searches counted again for every impression. Searches sit in search_queries and campaign exposure in ad_impressions, connected only by the user behind each event: count each campaign's searches once apiece, broken down by the result position the searcher clicked, with searches that ended without a click as their own bucket. List the campaigns alphabetically and, within each, the no-click bucket first, then the clicked positions from lowest to highest.
Searched but never clicked.
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 |
ad_impressions
Columns: impression_id INTEGER, user_id INTEGER, ad_campaign TEXT, impression_time TEXT, clicked INTEGER, revenue REAL
| impression_id | user_id | ad_campaign | impression_time | clicked | revenue |
|---|---|---|---|---|---|
| 253 | 100 | BRAND_AWARENESS_Q1 | 2026-02-02 01:07:00 | 0 | NULL |
| 306 | 197 | PRODUCT_LAUNCH_X | 2026-03-03 02:14:00 | 0 | NULL |
| 359 | 294 | RETARGETING_CART | 2026-04-04 03:21:00 | 0 | NULL |
| 412 | 391 | HOLIDAY_PROMO | 2026-05-05 04:28:00 | 0 | NULL |
| 465 | 488 | NEW_USER_ACQU | 2026-06-06 05:35:00 | 1 | 0.2 |
| 518 | NULL | LOYALTY_PROGRAM | 2026-07-07 06:42:00 | 0 | NULL |
| 571 | 682 | FLASH_SALE_48H | 2026-08-08 07:49:00 | 0 | NULL |
| 624 | 779 | SUMMER_SALE_2024 | 2026-09-09 08:56:00 | 0 | NULL |
| 677 | 876 | BRAND_AWARENESS_Q1 | 2026-01-10 09:03:00 | 0 | NULL |
| 730 | 973 | PRODUCT_LAUNCH_X | 2026-02-11 10:10:00 | 1 | 0.35 |
Expected output
| ad_campaign | clicked_result | event_count |
|---|---|---|
| BRAND_AWARENESS_Q1 | NULL | 4 |
| BRAND_AWARENESS_Q1 | 2 | 2 |
| BRAND_AWARENESS_Q1 | 6 | 2 |
| FLASH_SALE_48H | NULL | 4 |
| LOYALTY_PROGRAM | NULL | 8 |
Worked solution and explanation
The metric hiding behind the join
This is a per-campaign search count wearing a campaign-attribution costume. A campaign never touches a search directly: the only bridge is user_id, so you join search_queries to ad_impressions on the user, then count searches per campaign and per clicked_result. The trap is what you count AFTER that join. Fire COUNT(*) and you count search-by-impression pairs, not searches: a user shown one campaign across three impressions has each of their searches counted three times. Every heavy user inflates their campaign's totals, and the position breakdown lands wrong for exactly the users who matter most.
- search_queries: query_id INTEGER PK, user_id INTEGER FK, clicked_result INTEGER, query_time TEXT
- ad_impressions: impression_id INTEGER PK, user_id INTEGER FK, ad_campaign TEXT, impression_time TEXT
- search_queries.user_id references ad_impressions.user_id
Build it
Step 1: Attribute searches to campaigns
Join search_queries to ad_impressions on user_id. The shared user is the ONLY thing linking a search to a campaign, so this join is what attributes each search to the campaigns its user was shown. Searches with no user, or by a user never shown an ad, fall out here, and that is correct: they belong to no campaign.
Step 2: Count each search once
Group by ad_campaign and clicked_result, and count with COUNT(DISTINCT s.query_id). The DISTINCT is the whole game: the user_id join fans out, so the raw joined rows overcount searches. Counting unique query_id values collapses that fan-out back to one per real search.
Step 3: Keep the no-click bucket and put it first
A search that ended without a click carries a NULL in clicked_result, and it stays as its own group: never filter it away. Sort by ad_campaign, then clicked_result. An ascending sort here puts NULL first, which is where the no-click bucket belongs; on an engine that sorts nulls last you would add NULLS FIRST.
SELECT
a.ad_campaign,
s.clicked_result,
COUNT(DISTINCT s.query_id) AS event_count
FROM search_queries s
JOIN ad_impressions a ON s.user_id = a.user_id
GROUP BY a.ad_campaign, s.clicked_result
ORDER BY a.ad_campaign, s.clicked_result- COUNT(*)
- Counts joined rows: one search by a user shown a campaign in 3 impressions counts 3 times. The totals look busier than reality, and the position breakdown skews toward the heaviest users.
- COUNT(DISTINCT `s.query_id`)
- Counts each search once per campaign, no matter how many impressions carried it. This is the number the business actually asked for.
Common follow-up questions
- How would you report the click-through rate per campaign, not just raw counts? (Count unique `query_id` values where `clicked_result IS NOT NULL`, divided by the campaign's unique search count.)
- What if a search should only attribute to impressions within an hour of the query? (Add a time-window predicate to the join, correlating `query_time` with `impression_time` so a search attributes only to nearby impressions.)
- How would you pivot clicked versus unclicked into two columns per campaign? (Conditional aggregation over the deduped searches, testing `clicked_result` for `NULL` to fill the two columns.)