Two Names, One 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
- L4
Problem
Our advertising team labels campaigns in loud uppercase (ad_impressions.ad_campaign, e.g. FLASH_SALE_48H) while the push-notification team writes lowercase slugs (push_notifs.campaign, e.g. flash_sale), and the two naming systems were never reconciled. Some are plainly the same effort under a different name because they share a theme word: 'flash', 'loyalty', or 'summer'. Pair every ad campaign with the push campaign it secretly matches on one of those theme words, walking through the ad campaign names in alphabetical order.
The ad team and the push team never agreed on naming. Find where they secretly meant the same thing.
Tables
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 |
push_notifs
Columns: notif_id INTEGER, user_id INTEGER, title TEXT, platform TEXT, status TEXT, sent_at TEXT, opened INTEGER, campaign TEXT
| notif_id | user_id | title | platform | status | sent_at | opened | campaign |
|---|---|---|---|---|---|---|---|
| 1053 | 100 | New message from team | android | pending | 2026-02-02 01:07:00 | 0 | winback_feb |
| 1106 | 197 | Weekly digest ready | web | failed | 2026-03-03 02:14:00 | 0 | promo_summer |
| 1159 | 294 | Price drop alert | iOS | bounced | 2026-04-04 03:21:00 | 1 | onboard_v2 |
| 1212 | 391 | Security alert | Android | Delivered | 2026-05-05 04:28:00 | 0 | NULL |
| 1265 | 488 | Payment received | WEB | FAILED | 2026-06-06 05:35:00 | NULL | loyalty_2024 |
| 1318 | 585 | Complete your profile | basic | delivered | 2026-07-07 06:42:00 | 1 | NULL |
| 1371 | 682 | New feature available | premium | pending | 2026-08-08 07:49:00 | 0 | reengagement |
| 1424 | 779 | Subscription renewal | ios | failed | 2026-09-09 08:56:00 | 0 | NULL |
| 1477 | 876 | Flash sale: 50% off | android | bounced | 2026-01-10 09:03:00 | 1 | flash_sale |
| 1530 | 973 | Your order shipped! | web | Delivered | 2026-02-11 10:10:00 | NULL | retention_q1 |
Expected output
| ad_campaign | push_campaign |
|---|---|
| FLASH_SALE_48H | flash_sale |
| LOYALTY_PROGRAM | loyalty_2024 |
| SUMMER_SALE_2024 | promo_summer |
Worked solution and explanation
What you're actually being asked
Strip the marketing costume and this is a fuzzy join across a keyword whitelist. Two systems named the same campaign differently, and the only bridge between them is a shared theme word. Nobody hands you 'FLASH_SALE_48H' = 'flash_sale' as a key; you manufacture the join predicate yourself out of LOWER plus LIKE. Anyone can type JOIN. What separates candidates is where they put the LOWER and how they group the keyword conditions.
The trap: precedence and one-sided casing
Step 1: Normalize both sides
Wrap both campaign columns in LOWER so casing stops mattering. Case is the whole reason the two teams' data never lined up, so fix it at the comparison, not by rewriting the source.
Step 2: Build one predicate per theme word
For each keyword, require it to appear on the ad side AND the push side. That AND is what makes it a genuine match rather than 'either side happens to mention flash'.
Step 3: Dedupe the pairs
ad_impressions carries many rows per campaign, so the raw join emits the same (ad, push) pair once per impression. DISTINCT collapses them back to one row per real match.
SELECT DISTINCT
ai.ad_campaign,
pn.campaign AS push_campaign
FROM ad_impressions ai
JOIN push_notifs pn
ON (LOWER(ai.ad_campaign) LIKE '%flash%' AND LOWER(pn.campaign) LIKE '%flash%')
OR (LOWER(ai.ad_campaign) LIKE '%loyalty%' AND LOWER(pn.campaign) LIKE '%loyalty%')
OR (LOWER(ai.ad_campaign) LIKE '%summer%' AND LOWER(pn.campaign) LIKE '%summer%')
ORDER BY ai.ad_campaign;One AND-pair per theme word, OR'd together, both sides lowered, deduped.
- Naive: equality join
JOIN push_notifs pn ON ai.ad_campaign = pn.campaignreturns zero rows. The uppercase names and the lowercase slugs are never equal strings.- Correct: keyword bridge
- Match on a shared lowered keyword with
LOWER(...) LIKE '%kw%'on both sides, OR-ing one AND-pair per theme word, thenDISTINCT.
Common follow-up questions
- The theme keyword list is hard-coded. How would you scale this to hundreds of themes without editing the query for each new one? (Tests whether they reach for a keyword lookup table joined in, rather than an ever-growing OR chain.)
- Two different ad campaigns both contain 'sale'. How do you keep them from being treated as the same effort? (Probes the awareness that substring matching over-matches and needs a curated keyword set or token boundaries.)
- How would you also surface ad campaigns that have NO push counterpart at all? (Pushes them from an inner join to a left join with a null-side filter.)