The Price of a Tap

An easy SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.

Domain
SQL
Difficulty
easy
Seniority
L3
Asked in
Uber

Problem

For every named push campaign, broken out by the year it ran, marketing needs to know how hard it worked to earn a customer: how many notifications it took to win a single open. Count only the notifications that actually went out, not the ones still sitting in the queue as pending.

The campaign fired thousands of pushes. Find what each open really cost.

Tables

push_notifs

Columns: notif_id INTEGER, user_id INTEGER, title TEXT, platform TEXT, status TEXT, sent_at TEXT, opened INTEGER, campaign TEXT

push_notifs: 10 of 200 rows
notif_iduser_idtitleplatformstatussent_atopenedcampaign
1053100New message from teamandroidpending2026-02-02 01:07:000winback_feb
1106197Weekly digest readywebfailed2026-03-03 02:14:000promo_summer
1159294Price drop alertiOSbounced2026-04-04 03:21:001onboard_v2
1212391Security alertAndroidDelivered2026-05-05 04:28:000NULL
1265488Payment receivedWEBFAILED2026-06-06 05:35:00NULLloyalty_2024
1318585Complete your profilebasicdelivered2026-07-07 06:42:001NULL
1371682New feature availablepremiumpending2026-08-08 07:49:000reengagement
1424779Subscription renewaliosfailed2026-09-09 08:56:000NULL
1477876Flash sale: 50% offandroidbounced2026-01-10 09:03:001flash_sale
1530973Your order shipped!webDelivered2026-02-11 10:10:00NULLretention_q1

Expected output

First 5 of 10 rows
campaignyearcost_per_open
flash_sale20251.75
onboard_v220241.75
promo_summer20233.3333333333333335
reengagement20232
winback_feb20222

Worked solution and explanation

What this problem is really testing

Strip off the marketing language and this is a ratio of two aggregates measured over the same group: total notifications sent divided by total opens, per campaign per year. Anyone can write a COUNT and a SUM. What separates candidates is scoping the population and guarding the denominator. The open count is SUM(opened), and for a campaign whose outcomes were never recorded that sum comes back NULL, not a number. Divide by it and you emit a garbage row instead of a cost. The real skill being probed is computing count over sum per group while keeping only named campaigns and notifications that actually left the queue, then dropping any group with no recorded open.


The trap: a denominator that isn't there

SUM(opened) is your acquisition count. For a campaign and year where nobody opened, or where every outcome is still unrecorded, that sum is 0 or NULL. Divide COUNT(*) by it and you get a divide-by-zero or a NULL cost that quietly joins the result set. The fix is a HAVING SUM(opened) > 0 filter that runs after grouping, so those groups never reach the division. A WHERE clause cannot do this: the group sum does not exist until the group is formed.


Break down the requirements

Step 1: Scope to named campaigns that actually went out

WHERE campaign IS NOT NULL drops the anonymous bucket that a GROUP BY would otherwise build for rows with no campaign. Pair it with status NOT LIKE 'pend%' so notifications still queued as 'pending', which never reached anyone, stay out of the sent count. NOT LIKE also rides over the messy casing in status ('Delivered', 'delivered', 'FAILED'), which a case-sensitive equality test would trip on.

Step 2: Bucket by year

strftime('%Y', sent_at) turns each sent_at timestamp into its four-digit year as text. Group by campaign and that same year expression together so a campaign that ran in both 2025 and 2026 stays two separate rows.

Step 3: Divide, then guard the denominator

COUNT(*) * 1.0 / SUM(opened) is notifications per open. The * 1.0 forces real division instead of integer truncation. HAVING SUM(opened) > 0 drops any campaign-year that earned no recorded open, the only groups where the cost is undefined.


The solution

Cost per open, per campaign per year
SELECT
    campaign,
    strftime('%Y', sent_at) AS year,
    COUNT(*) * 1.0 / SUM(opened) AS cost_per_open
FROM push_notifs
WHERE campaign IS NOT NULL
  AND status NOT LIKE 'pend%'
GROUP BY campaign, strftime('%Y', sent_at)
HAVING SUM(opened) > 0
Naive: no denominator guard
Drop the HAVING clause and every campaign-year with no recorded open returns a NULL or divide-by-zero cost. On real send data whole campaigns go out with their open outcomes never logged, so these rows are common, not a rare edge case.
Correct: HAVING guard
HAVING SUM(opened) > 0 removes those groups after aggregation, so the output only contains campaign-years that actually earned an open, the only case where cost per open is even defined.

Common follow-up questions