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

search_queries: 10 of 200 rows
query_iduser_idsearch_termresults_countclicked_resultquery_time
2071100laptop deals1722026-02-02 01:11:00
2142197phone case iphone 153432026-03-03 02:22:00
2213294usb-c cable 6ft51NULL2026-04-04 03:33:00
2284NULLgaming mouse6852026-05-05 04:44:00
2355488mechanical keyboard8562026-06-06 05:55:00
24265854k monitor102NULL2026-07-07 06:06:00
2497682standing desk11982026-08-08 07:17:00
2568NULLwebcam 1080p13692026-09-09 08:28:00
2639876bluetooth speaker153NULL2026-01-10 09:39:00
2710973smart watch17012026-02-11 10:50:00

ad_impressions

Columns: impression_id INTEGER, user_id INTEGER, ad_campaign TEXT, impression_time TEXT, clicked INTEGER, revenue REAL

ad_impressions: 10 of 200 rows
impression_iduser_idad_campaignimpression_timeclickedrevenue
253100BRAND_AWARENESS_Q12026-02-02 01:07:000NULL
306197PRODUCT_LAUNCH_X2026-03-03 02:14:000NULL
359294RETARGETING_CART2026-04-04 03:21:000NULL
412391HOLIDAY_PROMO2026-05-05 04:28:000NULL
465488NEW_USER_ACQU2026-06-06 05:35:0010.2
518NULLLOYALTY_PROGRAM2026-07-07 06:42:000NULL
571682FLASH_SALE_48H2026-08-08 07:49:000NULL
624779SUMMER_SALE_20242026-09-09 08:56:000NULL
677876BRAND_AWARENESS_Q12026-01-10 09:03:000NULL
730973PRODUCT_LAUNCH_X2026-02-11 10:10:0010.35

Expected output

First 5 of 36 rows
ad_campaignclicked_resultevent_count
BRAND_AWARENESS_Q1NULL4
BRAND_AWARENESS_Q122
BRAND_AWARENESS_Q162
FLASH_SALE_48HNULL4
LOYALTY_PROGRAMNULL8

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.

`user_id` is the only bridge, and it is many-to-many on both sides.
  • 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.

Deduplicated per-campaign search count
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