How to calculate CTR in SQL
Contents:
- What CTR is and why it matters
- The SQL formula
- Overall CTR for a period
- CTR by channel
- Week-over-week change
- CTR by campaign with traffic share
- Blended CTR weighted by impressions
- CTR with a confidence interval
- A/B test comparison
- Typical CTR benchmarks
- Common pitfalls
- Optimization tips
- Related reading
- FAQ
What CTR is and why it matters
CTR stands for Click-Through Rate, the share of impressions that turned into clicks. It is the lever that quietly controls CPC, CAC, ROAS, and payback. Plug it into CAC = CPM / (CTR * CR) and every percentage point of CTR you lose raises customer acquisition cost. If CTR drops, CPC rises through the auction, CAC inflates, and the marketing lead starts asking uncomfortable questions on the Monday standup.
In an interview, a hiring manager at Meta, Google, Stripe, or DoorDash will ask you to compute CTR by channel, then week over week, then add a confidence interval, then compare two creatives from a live A/B test. Each step is a single GROUP BY, but traps in the denominator, casting, and weighted versus simple aggregation trip up candidates inside ten minutes. Typical schema is event-grained ad_events(user_id, event_type, channel, campaign, event_at) or daily aggregated ad_stats(channel, campaign, day, impressions, clicks).
The SQL formula
The arithmetic is trivial. CTR is clicks divided by impressions, multiplied by 100 for a percentage. The interesting part is everything around the division — which clicks count, which impressions count, how you guard against zero, and at what grain you aggregate before you divide.
CTR = clicks / impressions * 100%Always wrap the divisor in NULLIF(SUM(impressions), 0) so a zero-impression campaign returns NULL rather than crashing. Cast at least one side to numeric — 100.0 * SUM(clicks) or SUM(clicks)::NUMERIC — because integer division silently floors a 3 percent CTR to zero.
Overall CTR for a period
This is the dashboard tile when leadership asks for one number. Run it against ad_events for raw events.
SELECT
COUNT(*) FILTER (WHERE event_type = 'click')::FLOAT /
NULLIF(COUNT(*) FILTER (WHERE event_type = 'impression'), 0) AS ctr
FROM ad_events
WHERE event_at >= CURRENT_DATE - INTERVAL '30 days';If your warehouse does not support FILTER — older Redshift, for example — the portable form uses a conditional sum. If CTR reads zero on a campaign that is delivering, check the cast — without 100.0 or ::FLOAT, integer division rounds any rate below 1 percent down to nothing.
SELECT
100.0 * SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) /
NULLIF(SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END), 0) AS ctr_pct
FROM ad_events
WHERE event_at >= CURRENT_DATE - INTERVAL '30 days';CTR by channel
A single blended number hides the diagnostic signal. To see which channel is dragging the average, break the calculation down with a GROUP BY on channel, ordered by CTR descending.
SELECT
channel,
SUM(impressions) AS imps,
SUM(clicks) AS clicks,
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr_pct
FROM ad_stats
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY channel
ORDER BY ctr_pct DESC;Sort a second view by spend so you can tell which channels move the blended number versus which have weird CTR on tiny budgets. A 12 percent CTR on a 50 dollar branded campaign is operationally irrelevant. A 0.4 percent CTR on a 200 thousand dollar prospecting campaign wakes the CMO at 2am.
Week-over-week change
Daily numbers are noisy. Week-over-week is what the executive team actually reads, and LAG gives you both the prior week and the delta in one row.
WITH weekly AS (
SELECT
DATE_TRUNC('week', day) AS week,
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr
FROM ad_stats
GROUP BY 1
)
SELECT
week,
ctr,
LAG(ctr) OVER (ORDER BY week) AS prev_ctr,
ctr - LAG(ctr) OVER (ORDER BY week) AS wow_diff,
(ctr - LAG(ctr) OVER (ORDER BY week)) /
NULLIF(LAG(ctr) OVER (ORDER BY week), 0) * 100 AS wow_pct
FROM weekly
ORDER BY week;The percent-change column lets the reader tell whether a 0.3 percentage-point swing is a 5 percent shift on a high-CTR search campaign or a 60 percent collapse on a low-CTR display network.
CTR by campaign with traffic share
For paid media reviews, CTR alone is not enough — you also need to know how much of the impression budget each campaign consumed. A 0.6 percent CTR on a campaign carrying 40 percent of impressions is the report you bring to weekly review.
SELECT
campaign,
SUM(impressions) AS imps,
SUM(clicks) AS clicks,
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr_pct,
100.0 * SUM(impressions) /
SUM(SUM(impressions)) OVER () AS traffic_share_pct
FROM ad_stats
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY campaign
ORDER BY imps DESC;The nested SUM(SUM(impressions)) OVER () is the standard share-of-total pattern with no second pass, and it works on Postgres, Snowflake, BigQuery, and Redshift.
Blended CTR weighted by impressions
This is the question that most often separates senior from junior in an interview. A simple average of campaign-level CTRs weights every campaign equally regardless of traffic. Blended CTR weights by impression volume.
-- WRONG: simple mean of campaign CTRs
SELECT AVG(ctr_pct) AS naive_avg_ctr
FROM (
SELECT campaign,
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr_pct
FROM ad_stats
GROUP BY campaign
) t;
-- RIGHT: weighted by impressions
SELECT
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS blended_ctr_pct
FROM ad_stats;Imagine one remarketing campaign at 10 percent CTR carrying 80 percent of impressions, plus ten experiments at 0.5 percent CTR with the remaining 20 percent. Simple mean is about 1.4 percent. Weighted is about 8.1 percent. Same data, factor of five apart.
CTR with a confidence interval
For any campaign comparison where you have to defend "is the difference real," CTR needs a confidence interval. The Wald approximation works above a few thousand impressions, which most paid campaigns clear inside a day.
SELECT
channel,
clicks,
impressions,
ctr,
ctr - 1.96 * SQRT(ctr * (1 - ctr) / impressions) AS ci_lower,
ctr + 1.96 * SQRT(ctr * (1 - ctr) / impressions) AS ci_upper
FROM (
SELECT
channel,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions,
SUM(clicks)::FLOAT / NULLIF(SUM(impressions), 0) AS ctr
FROM ad_stats
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY channel
) t;Two channels with overlapping confidence intervals are statistically indistinguishable, no matter what the point estimates say. For very low CTRs near 0.1 percent where the normal approximation breaks, switch to Wilson or Clopper-Pearson.
A/B test comparison
For A/B testing creatives, the query mirrors the channel breakdown with variant in place of channel. The interesting follow-up is the significance test.
SELECT
variant,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions,
100.0 * SUM(clicks) / NULLIF(SUM(impressions), 0) AS ctr_pct
FROM experiment
WHERE day >= '2026-05-01'
GROUP BY variant;For significance in pure SQL, compute pooled rate p = (c1 + c2) / (n1 + n2), standard error sqrt(p * (1 - p) * (1/n1 + 1/n2)), and z statistic (p1 - p2) / SE. Compare absolute z to 1.96 for a 95 percent two-sided test. The A/B testing peeking mistake writeup covers why you should not call winners mid-experiment.
Typical CTR benchmarks
Search ads run 3 to 5 percent on Google, branded queries routinely above 10 percent. Display ads sit in 0.1 to 0.5 percent because users are not in active search mode. Paid social — Meta, TikTok, LinkedIn — usually lands in 1 to 2 percent. Email on delivered runs 2 to 5 percent on a healthy list. Push notifications reach 1 to 7 percent on a well-segmented audience. If your display CTR sits at 3 percent, that is almost certainly bot traffic.
Common pitfalls
The first trap is dividing without a NULL guard. A campaign with zero impressions crashes a Postgres query with a division-by-zero error and silently returns infinity or NaN in BigQuery and Snowflake depending on the version. The fix is NULLIF(SUM(impressions), 0) so an empty campaign returns NULL and the dashboard renders an em dash. This single idiom catches more production bugs than any other pattern in marketing analytics.
The second trap is integer division. In Postgres, 100 * 5 / 200 evaluates to zero because both sides are integers and the result is floored. Use 100.0 * 5 / 200 or cast one side to NUMERIC and you get 2.5 percent. This bug almost never throws an error, so it can sit in production for months quietly flattening every CTR below 1 percent to zero.
The third trap is the average of averages. Computing the simple mean of campaign-level CTRs and calling it "the blended CTR" is the most common interview mistake at the senior data analyst level. Always weight by impressions, which reduces to total clicks divided by total impressions. The simple-mean answer is only correct when every campaign has identical impression volume — almost never true in production.
The fourth trap is invalid traffic. Bot clicks, click farms, and accidental swipes inflate the numerator and push reported CTR above the real engagement rate. Google Ads and Meta filter most invalid traffic before final reports, but the lag is two to three days on Google and up to seven on smaller networks. CTR from same-day data will later be revised downward. Label same-day numbers as preliminary.
The fifth trap is the impression definition. Meta reports "impressions" — times your ad was served — and "reach" — unique people who saw it. The two can differ by a factor of two or three on retargeting campaigns. Dividing clicks by reach instead of impressions inflates CTR by the average frequency, which makes the number look great and the downstream CAC math fall apart.
Optimization tips
For daily and channel queries on a 30 to 90 day window, the dominant cost is the scan of ad_stats. Partition by day and cluster by channel on Snowflake or BigQuery — this drops a 90-day scan from seconds to milliseconds on multi-billion-row tables. On Redshift, set the same fields as the sort key.
If the CTR dashboard refreshes daily, materialize the channel-day aggregate as a separate table or a Snowflake dynamic table refreshed hourly. Downstream queries for blended, weekly trends, and campaign rollups then run against thousands of pre-aggregated rows instead of billions of raw events. On Postgres, index ad_stats on (channel, day) and (campaign, day).
Related reading
- How to calculate CPC in SQL
- How to calculate CPM in SQL
- How to calculate CAC in SQL
- How to calculate CAC by channel in SQL
- How to calculate confidence interval in SQL
- A/B testing peeking mistake
- SQL window functions interview questions
If you want to drill marketing analytics SQL questions like this every day before your next interview, NAILDD is launching with 500+ SQL problems covering exactly this pattern.
FAQ
Is CTR measured on impressions or on delivered?
For paid advertising on Google, Meta, LinkedIn, TikTok, and the display networks, CTR is measured on impressions — the count of times your ad rendered on a user's screen. For email the convention shifts. CTR there is almost always reported on delivered, meaning emails that reached the inbox and were not bounced or filtered as spam. The conventions exist because impression measurement is platform-controlled on paid ads, while email deliverability is itself a meaningful denominator.
How do I report blended CTR correctly?
Blended CTR is always a weighted average by impressions, not a simple mean of campaign-level rates. Mathematically this reduces to SUM(clicks) / SUM(impressions) over the dataset. The simple-mean approach over-weights small campaigns and under-weights large ones, which can move the reported number by a factor of two or more in a real portfolio.
What is a healthy CTR for paid search?
Branded search keywords run 10 to 20 percent CTR because the user already knew what they wanted. Non-branded keywords land in the 3 to 5 percent range on Google. CTR below 1 percent on a non-branded campaign usually signals one of three things — ad copy that does not match search intent, ad rank too low to appear above the fold, or audience targeting pulling in mismatched queries.
How long does an A/B test on CTR need to run?
Long enough for confidence intervals on the two variants to stop overlapping. For a baseline CTR around 2 percent and a target lift of 10 percent, you typically need 30 to 50 thousand impressions per variant for a clean 95 percent significant result. For a 0.5 percent baseline on display, the same relative lift needs hundreds of thousands per variant. Use a power calculator before launching.
Why does my CTR jump if I switch from "all clicks" to "link clicks"?
Meta reports two distinct click metrics. "All clicks" includes every interaction on the ad — likes, reactions, profile clicks, comments, carousel expansion, plus the link click itself. "Link clicks" counts only the click that drives a user to your landing page. On engagement-heavy creatives, all clicks can exceed link clicks by 30 to 50 percent. Standardize on link clicks across platforms.