1. The mental model first
Imagine you're working with an AdTech platform.
An advertiser runs campaigns:
A publisher generates inventory:
For example:
| Campaign | Advertiser | Impressions | Clicks | Spend |
|---|---|---|---|---|
| Nike CTV | Nike | 1,000,000 | 8,500 | โน80,000 |
| CTV Display | Vega | 500,000 | 3,000 | โน30,000 |
| PhonePe Video | PhonePe | 2,000,000 | 12,000 | โน100,000 |
From this raw data, SQL allows you to answer:
That's exactly the type of SQL useful for AdTech Solution Engineering / TAM / Technical Account Management.
2. First: understand SQL execution order
This is extremely important for interviews.
When you write:
SELECT campaign_id, SUM(spend) FROM campaign_performance WHERE date >= '2026-09-01' GROUP BY campaign_id HAVING SUM(spend) > 10000 ORDER BY SUM(spend) DESC;
You write SQL approximately in this order:
SELECT FROM WHERE GROUP BY HAVING ORDER BY
But conceptually SQL processes it more like:
The important interview distinction:
WHERE vs HAVING
WHERE filters rows before aggregation.
WHERE impressions > 1000
HAVING filters groups after aggregation.
HAVING SUM(spend) > 10000
Use:
HAVING SUM(spend) > 10000
3. Let's build our AdTech database
Open DBeaver → MySQL connection → SQL Editor.
Run this first:
CREATE DATABASE adtech_training; USE adtech_training;
Now create our tables.
Advertisers
CREATE TABLE advertisers ( advertiser_id INT PRIMARY KEY, advertiser_name VARCHAR(100) );
Campaigns
CREATE TABLE campaigns ( campaign_id INT PRIMARY KEY, advertiser_id INT, campaign_name VARCHAR(100), campaign_type VARCHAR(50), start_date DATE, end_date DATE, FOREIGN KEY (advertiser_id) REFERENCES advertisers(advertiser_id) );
Publishers
CREATE TABLE publishers ( publisher_id INT PRIMARY KEY, publisher_name VARCHAR(100) );
Campaign performance
This is our most important table.
CREATE TABLE campaign_performance ( performance_id INT PRIMARY KEY AUTO_INCREMENT, campaign_id INT, publisher_id INT, report_date DATE, impressions INT, clicks INT, spend DECIMAL(12,2), ad_requests INT, wins INT, FOREIGN KEY (campaign_id) REFERENCES campaigns(campaign_id), FOREIGN KEY (publisher_id) REFERENCES publishers(publisher_id) );
4. Insert realistic data
Advertisers
INSERT INTO advertisers (advertiser_id, advertiser_name) VALUES (1, 'Nike'), (2, 'Vega'), (3, 'PhonePe'), (4, 'Adobe'), (5, 'Amazon');
Campaigns
INSERT INTO campaigns (campaign_id, advertiser_id, campaign_name, campaign_type, start_date, end_date) VALUES (101, 1, 'Nike CTV Summer', 'CTV', '2026-08-01', '2026-09-30'), (102, 2, 'Galaxy CTV', 'Display', '2026-08-15', '2026-09-30'), (103, 3, 'PhonePe Video', 'Video', '2026-08-20', '2026-09-30'), (104, 4, 'Adobe Creative Cloud', 'Display', '2026-08-10', '2026-09-30'), (105, 5, 'Amazon Prime', 'CTV', '2026-08-05', '2026-09-30');
Publishers
INSERT INTO publishers (publisher_id, publisher_name) VALUES (201, 'StreamVault'), (202, 'YouTube'), (203, 'NDTV'), (204, 'Times Internet'), (205, 'Sony LIV');
Performance
INSERT INTO campaign_performance (campaign_id, publisher_id, report_date, impressions, clicks, spend, ad_requests, wins) VALUES (101, 201, '2026-09-01', 500000, 4500, 40000, 700000, 500000), (101, 205, '2026-09-02', 550000, 4000, 42000, 750000, 550000), (102, 203, '2026-09-01', 300000, 2500, 18000, 500000, 300000), (102, 204, '2026-09-02', 250000, 500, 16000, 450000, 250000), (103, 202, '2026-09-01', 800000, 7000, 45000, 1000000, 800000), (103, 205, '2026-09-02', 900000, 5000, 55000, 1200000, 900000), (104, 204, '2026-09-01', 200000, 1000, 12000, 300000, 200000), (104, 203, '2026-09-02', 180000, 0, 11000, 250000, 180000), (105, 201, '2026-09-01', 1000000, 15000, 70000, 1500000, 1000000), (105, 205, '2026-09-02', 1200000, 16000, 85000, 1700000, 1200000);
Now test:
SELECT * FROM campaign_performance;
5. SQL Core #1 — SELECT
Start simple.
SELECT campaign_id, impressions, clicks, spend FROM campaign_performance;
You're saying:
6. SQL Core #2 — WHERE
Suppose I only want CTV-related campaigns.
We need the campaign table:
SELECT * FROM campaigns WHERE campaign_type = 'CTV';
Or performance after September 1:
SELECT * FROM campaign_performance WHERE report_date >= '2026-09-01';
Multiple conditions:
SELECT * FROM campaign_performance WHERE impressions > 500000 AND clicks > 5000;
7. SQL Core #3 — GROUP BY
Now we're getting into reporting SQL.
Suppose we want total impressions by campaign.
SELECT campaign_id, SUM(impressions) AS total_impressions FROM campaign_performance GROUP BY campaign_id;
Why SUM()?
Because campaign 101 has multiple rows.
We want:
8. Q1 — Total impressions, clicks and spend by campaign
Your first interview question.
SELECT campaign_id, SUM(impressions) AS total_impressions, SUM(clicks) AS total_clicks, SUM(spend) AS total_spend FROM campaign_performance GROUP BY campaign_id;
This is your first campaign performance report.
9. Q2 — CTR, CPC and CPM
This is extremely important for AdTech interviews.
CTR (Click-Through Rate): CTR = Clicks / Impressions × 100
CPC (Cost Per Click): CPC = Spend / Clicks
CPM (Cost Per Mille): CPM = Spend / Impressions × 1000
In SQL:
SELECT campaign_id, SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(spend) AS spend, ROUND( SUM(clicks) / NULLIF(SUM(impressions), 0) * 100, 2 ) AS ctr_percent, ROUND( SUM(spend) / NULLIF(SUM(clicks), 0), 2 ) AS cpc, ROUND( SUM(spend) / NULLIF(SUM(impressions), 0) * 1000, 2 ) AS cpm FROM campaign_performance GROUP BY campaign_id;
Why NULLIF()?
Suppose:
Clicks = 0
Then: spend / clicks becomes division by zero.
Instead:
So: โน10,000 / NULL returns NULL rather than crashing the calculation.
This is a very useful production SQL habit.
10. Q3 — Top 5 campaigns by spend
SELECT campaign_id, SUM(spend) AS total_spend FROM campaign_performance GROUP BY campaign_id ORDER BY total_spend DESC LIMIT 5;
This is one of the most common reporting queries.
Notice:
Then LIMIT 5 means only five rows.
11. Q4 — Campaigns with CTR below 1%
This is where HAVING becomes important.
Wrong approach:
WHERE ctr < 1 — because ctr is calculated from an aggregate.
Correct:
SELECT campaign_id, ROUND( SUM(clicks) / NULLIF(SUM(impressions), 0) * 100, 2 ) AS ctr_percent FROM campaign_performance GROUP BY campaign_id HAVING ctr_percent < 1;
This is a classic interview question.
12. JOIN — The heart of real-world SQL
Our performance table contains:
campaign_idpublisher_id
But not:
campaign_namepublisher_nameadvertiser_name
Those live in other tables. So we JOIN them.
SELECT c.campaign_name, p.publisher_name, cp.impressions, cp.clicks, cp.spend FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id JOIN publishers p ON cp.publisher_id = p.publisher_id;
Think:
13. JOIN types you need to know
INNER JOIN
Only matching records.
SELECT * FROM campaigns c INNER JOIN advertisers a ON c.advertiser_id = a.advertiser_id;
Meaning: Give me campaigns that have a matching advertiser.
LEFT JOIN
Keep everything from the left table.
SELECT * FROM campaigns c LEFT JOIN advertisers a ON c.advertiser_id = a.advertiser_id;
Even if an advertiser doesn't match, the campaign remains.
This is very important for troubleshooting.
Example: "Show me all campaigns, including campaigns that have no performance data."
SELECT c.campaign_name, cp.impressions, cp.clicks FROM campaigns c LEFT JOIN campaign_performance cp ON c.campaign_id = cp.campaign_id;
14. NULL handling
Suppose a campaign has no performance.
A LEFT JOIN may produce:
campaign_name: Adobe Campaignimpressions: NULL
You can convert NULL to zero:
Example:
SELECT c.campaign_name, COALESCE(SUM(cp.impressions), 0) AS impressions, COALESCE(SUM(cp.clicks), 0) AS clicks FROM campaigns c LEFT JOIN campaign_performance cp ON c.campaign_id = cp.campaign_id GROUP BY c.campaign_id, c.campaign_name;
COALESCE() is another function you should become very comfortable with.
15. CASE WHEN
Suppose we want to classify campaigns.
CTR >= 1% → Good
CTR < 1% → Poor
SQL:
SELECT campaign_id, ROUND( SUM(clicks) / NULLIF(SUM(impressions), 0) * 100, 2 ) AS ctr, CASE WHEN SUM(clicks) / NULLIF(SUM(impressions), 0) * 100 >= 2 THEN 'Excellent' WHEN SUM(clicks) / NULLIF(SUM(impressions), 0) * 100 >= 1 THEN 'Good' ELSE 'Poor' END AS performance_category FROM campaign_performance GROUP BY campaign_id;
CASE WHEN is basically SQL's:
IF / ELSE IF / ELSE
16. Q5 — Publishers with highest revenue
Our current table has advertiser spend.
For a publisher revenue report, let's add revenue.
ALTER TABLE campaign_performance ADD COLUMN publisher_revenue DECIMAL(12,2);
Populate it:
UPDATE campaign_performance SET publisher_revenue = CASE WHEN publisher_id = 201 THEN spend * 0.70 WHEN publisher_id = 202 THEN spend * 0.65 WHEN publisher_id = 203 THEN spend * 0.60 WHEN publisher_id = 204 THEN spend * 0.55 WHEN publisher_id = 205 THEN spend * 0.68 END;
Now:
SELECT p.publisher_name, SUM(cp.publisher_revenue) AS total_revenue FROM campaign_performance cp JOIN publishers p ON cp.publisher_id = p.publisher_id GROUP BY p.publisher_id, p.publisher_name ORDER BY total_revenue DESC;
This gives you a publisher revenue ranking.
17. Q6 — Fill Rate by publisher
This is an important AdTech KPI.
Simplified formula:
Our dataset uses impressions as filled impressions.
SELECT p.publisher_name, SUM(cp.ad_requests) AS ad_requests, SUM(cp.impressions) AS impressions, ROUND( SUM(cp.impressions) / NULLIF(SUM(cp.ad_requests), 0) * 100, 2 ) AS fill_rate_percent FROM campaign_performance cp JOIN publishers p ON cp.publisher_id = p.publisher_id GROUP BY p.publisher_id, p.publisher_name;
Real-world troubleshooting
Suppose:
Requests: 1,000,000
Impressions: 400,000
Fill Rate: 40%
A Solution Engineer shouldn't simply say: "Fill rate is low."
You investigate:
That is where SQL becomes Solution Engineering, rather than just reporting.
18. Win Rate
Another important programmatic metric:
Using our data:
SELECT publisher_id, SUM(wins) AS wins, SUM(ad_requests) AS requests, ROUND( SUM(wins) / NULLIF(SUM(ad_requests), 0) * 100, 2 ) AS win_rate_percent FROM campaign_performance GROUP BY publisher_id;
19. Q7 — Daily impressions trend
SELECT report_date, SUM(impressions) AS total_impressions FROM campaign_performance WHERE report_date >= CURRENT_DATE - INTERVAL 30 DAY GROUP BY report_date ORDER BY report_date;
This is how you build a daily delivery trend.
In a real job, you'd put this into:
20. Q8 — Advertisers spending more than โน10,000
Now we're joining three tables.
SELECT a.advertiser_name, SUM(cp.spend) AS total_spend FROM advertisers a JOIN campaigns c ON a.advertiser_id = c.advertiser_id JOIN campaign_performance cp ON c.campaign_id = cp.campaign_id GROUP BY a.advertiser_id, a.advertiser_name HAVING SUM(cp.spend) > 10000 ORDER BY total_spend DESC;
Notice the pattern:
This type of multi-table reasoning is what I want you to become strong at.
21. Q9 — Campaigns with impressions but no clicks
Very common troubleshooting question.
SELECT campaign_id, SUM(impressions) AS impressions, SUM(clicks) AS clicks FROM campaign_performance GROUP BY campaign_id HAVING SUM(impressions) > 0 AND SUM(clicks) = 0;
This identifies:
Clicks = 0
Potential causes could include:
- tracking issue
- click event not firing
- incorrect VAST interaction tracking
- landing-page tracking issue
- campaign genuinely receiving no clicks
- attribution/event pipeline problem
22. Q10 — CPC above average CPC
This is slightly harder.
First calculate campaign-level CPC. Then compare each campaign with the average campaign CPC.
A clean approach is a CTE (Common Table Expression):
WITH campaign_cpc AS ( SELECT campaign_id, SUM(spend) / NULLIF(SUM(clicks), 0) AS cpc FROM campaign_performance GROUP BY campaign_id ) SELECT campaign_id, ROUND(cpc, 2) AS cpc FROM campaign_cpc WHERE cpc > ( SELECT AVG(cpc) FROM campaign_cpc ) ORDER BY cpc DESC;
This is a major step up from basic SQL.
You're now thinking in multiple stages:
That's the kind of thinking I want you to develop.
23. Date functions you should know
For AdTech reporting, learn these:
| Purpose | Syntax | AdTech Use Case |
|---|---|---|
| Extract year | YEAR(report_date) |
Annual pacing & YoY spend comparisons |
| Extract month | MONTH(report_date) |
Monthly revenue reconciliation & billing |
| Extract day | DAY(report_date) |
Day-of-month delivery trending |
| Day name | DAYNAME(report_date) |
Weekend vs weekday traffic/conversion patterns |
| Date difference | DATEDIFF(end_date, start_date) |
Campaign flight duration & daily pacing calculation |
| Last 7 days | WHERE report_date >= CURRENT_DATE - INTERVAL 7 DAY |
Rolling weekly executive summary reports |
| Last 30 days | WHERE report_date >= CURRENT_DATE - INTERVAL 30 DAY |
Monthly inventory delivery & fill analysis |
24. Your first professional report
Now combine everything.
Try this:
SELECT c.campaign_name, a.advertiser_name, SUM(cp.impressions) AS impressions, SUM(cp.clicks) AS clicks, ROUND(SUM(cp.spend), 2) AS spend, ROUND( SUM(cp.clicks) / NULLIF(SUM(cp.impressions), 0) * 100, 2 ) AS ctr_percent, ROUND( SUM(cp.spend) / NULLIF(SUM(cp.clicks), 0), 2 ) AS cpc, ROUND( SUM(cp.spend) / NULLIF(SUM(cp.impressions), 0) * 1000, 2 ) AS cpm FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id JOIN advertisers a ON c.advertiser_id = a.advertiser_id GROUP BY c.campaign_id, c.campaign_name, a.advertiser_name ORDER BY spend DESC;
This is your Campaign Performance Report.
25. The five projects you should build
Don't just read SQL. Build these in DBeaver.
Project 1 — Campaign Performance Report
Target Output Columns:
Campaign | Advertiser | Impressions | Clicks | Spend | CTR | CPC | CPM
Project 2 — Publisher Revenue Report
Target Output Columns:
Publisher | Impressions | Ad Requests | Revenue | Fill Rate | Win Rate
Project 3 — Daily Trend Report
Target Output Columns:
Date | Impressions | Clicks | Spend | CTR
Then eventually visualize it in Power BI / Tableau / Looker.
Project 4 — Top 5 Campaigns
Target Output Columns:
Campaign | Advertiser | Spend | Impressions | CTRSorted by spend descending.
Project 5 — Fill Rate Report
Target Output Columns:
Publisher | Requests | Impressions | Fill RateThen investigate publishers below, for example, 60%.
26. Your learning progression
I don't want you to memorize 100 SQL commands.
I want you to progress like this:
SELECT • WHERE • ORDER BY • LIMIT
GROUP BY • SUM • COUNT • AVG • MIN • MAX
HAVING • CASE WHEN • NULL • COALESCE • NULLIF
INNER JOIN • LEFT JOIN • multiple JOINs
WITH (CTEs) • Subqueries • Date functions
ROW_NUMBER() • RANK() • DENSE_RANK() • LAG() • LEAD()
CTR • CPC • CPM • Fill Rate • Win Rate • Conversion Rate
Delivery drops • Tracking failures • Auction problems • Revenue discrepancies
Performance optimization • Indexes • EXPLAIN ANALYZE • Data modeling
Level 6–9 is where I want to take you eventually.
27. Your first exercise — don't look at the answer
Run the database we created in Section 3 & 4. Then solve these yourself in DBeaver before toggling the answers below:
Exercise 1: Find total spend across all campaigns
Solution Query:
SELECT SUM(spend) AS total_platform_spend FROM campaign_performance;
Output: โน394,000.00 across all 10 recorded rows.
Exercise 2: Find total impressions by publisher
Solution Query:
SELECT p.publisher_name, SUM(cp.impressions) AS total_impressions FROM campaign_performance cp JOIN publishers p ON cp.publisher_id = p.publisher_id GROUP BY p.publisher_id, p.publisher_name ORDER BY total_impressions DESC;
Key Result: Sony LIV leads with 2,650,000 impressions, followed by StreamVault with 1,500,000.
Exercise 3: Find the campaign with the highest CTR
Solution Query:
SELECT c.campaign_name, ROUND( SUM(cp.clicks) / NULLIF(SUM(cp.impressions), 0) * 100, 2 ) AS ctr_percent FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id GROUP BY c.campaign_id, c.campaign_name ORDER BY ctr_percent DESC LIMIT 1;
Winner: Amazon Prime (Campaign 105) with 1.41% CTR (31,000 clicks / 2,200,000 imps).
Exercise 4: Find campaigns where Impressions > 500,000 AND CTR < 1%
Solution Query:
SELECT c.campaign_name, SUM(cp.impressions) AS total_impressions, ROUND( SUM(cp.clicks) / NULLIF(SUM(cp.impressions), 0) * 100, 2 ) AS ctr_percent FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id GROUP BY c.campaign_id, c.campaign_name HAVING SUM(cp.impressions) > 500000 AND ctr_percent < 1.0;
Flagged Campaigns: Nike CTV Summer (0.81%), Galaxy CTV (0.55%), PhonePe Video (0.71%).
Exercise 5: Find total spend by advertiser
Solution Query:
SELECT a.advertiser_name, SUM(cp.spend) AS total_spend FROM advertisers a JOIN campaigns c ON a.advertiser_id = c.advertiser_id JOIN campaign_performance cp ON c.campaign_id = cp.campaign_id GROUP BY a.advertiser_id, a.advertiser_name ORDER BY total_spend DESC;
Exercise 6: Find publishers with fill rate below 70%
Solution Query:
SELECT p.publisher_name, SUM(cp.ad_requests) AS requests, SUM(cp.impressions) AS impressions, ROUND( SUM(cp.impressions) / NULLIF(SUM(cp.ad_requests), 0) * 100, 2 ) AS fill_rate_percent FROM campaign_performance cp JOIN publishers p ON cp.publisher_id = p.publisher_id GROUP BY p.publisher_id, p.publisher_name HAVING fill_rate_percent < 70.0 ORDER BY fill_rate_percent ASC;
Underperforming Publishers: Times Internet (60.00%), NDTV (64.00%), StreamVault (68.18%).
Exercise 7: Find campaigns that have more than 1 million impressions
Solution Query:
SELECT c.campaign_name, SUM(cp.impressions) AS total_impressions FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id GROUP BY c.campaign_id, c.campaign_name HAVING SUM(cp.impressions) > 1000000 ORDER BY total_impressions DESC;
Exercise 8: Find the average CPC across campaigns
Solution Query (using CTE):
WITH campaign_cpc AS ( SELECT campaign_id, SUM(spend) / NULLIF(SUM(clicks), 0) AS cpc FROM campaign_performance GROUP BY campaign_id ) SELECT ROUND(AVG(cpc), 2) AS platform_avg_cpc FROM campaign_cpc;
Average: โน11.46
Exercise 9 — Interview level: Top 3 campaigns by CPM
Solution Query:
SELECT c.campaign_name, SUM(cp.impressions) AS impressions, SUM(cp.spend) AS spend, ROUND( SUM(cp.spend) / NULLIF(SUM(cp.impressions), 0) * 1000, 2 ) AS cpm FROM campaign_performance cp JOIN campaigns c ON cp.campaign_id = c.campaign_id GROUP BY c.campaign_id, c.campaign_name ORDER BY cpm DESC LIMIT 3;
Top 3 CPMs: 1. Nike CTV Summer (โน78.10 CPM), 2. Amazon Prime (โน70.45 CPM), 3. Galaxy CTV (โน61.82 CPM).
Notice that premium CTV inventory commands much higher CPMs than standard display!
Exercise 10 — Solution Engineering level: Requests > 1M but Fill Rate < 70%
Solution Query:
SELECT p.publisher_name, SUM(cp.ad_requests) AS ad_requests, SUM(cp.impressions) AS impressions, ROUND( SUM(cp.impressions) / NULLIF(SUM(cp.ad_requests), 0) * 100, 2 ) AS fill_rate_percent FROM campaign_performance cp JOIN publishers p ON cp.publisher_id = p.publisher_id GROUP BY p.publisher_id, p.publisher_name HAVING SUM(cp.ad_requests) > 1000000 AND fill_rate_percent < 70.0;
Three Technical Reasons Why This Happens (Solution Engineer Root Cause Analysis):
-
Excessive Floor Prices (Price Floor / UPR Misconfiguration):
The publisher has configured hard CPM floors in Google Ad Manager (GAM 360) or their Prebid Server that are significantly higher than the fair market clearing price. DSPs submit valid bids, but the ad exchange discards them as floor-rejected (Loss Code 100 / floor price violation). -
Latency / Timeout (tmax) Dropped Bids:
The publisher's video player or wrapper timeout is set too strictly (e.g. 500ms for CTV or client-side Prebid). Downstream DSPs require 600โ900ms to evaluate user segments and respond. As a result, hundreds of thousands of bid responses arrive after the auction has already timed out. -
Identity & Consent Drop-off (No-Bid / Zero Addressability):
If the publisher is sending ad requests with missing or invalid IAB TCF v2.2 consent strings, missing GDPR signals, or stripped device identifiers (no IDFA, GAID, or PPID), DSPs refuse to bid due to compliance mandates or inability to frequency-cap.
Start becoming comfortable explaining SQL in this language:
That one sentence already sounds much more like someone who understands production reporting, rather than someone who has simply learned SQL syntax.
Do Exercises 1–10 in DBeaver and paste your SQL here — even if it's wrong. I'll review it like a Solution Engineering interview, point out exactly what is wrong, explain why, and then move you into CTEs → subqueries → window functions → real AdTech troubleshooting SQL.