Not All Fires Are Equal
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
- Asked in
- Affirm
Problem
Severity labels in alert_events were typed by hand, so 'HIGH' and 'high' are the same level, and an on-call lead only trusts alerts from services that also report into svc_health. For each severity, she wants the average alert count across the service-severity pairs that fired more than once in 2026, as a decimal rounded to one place.
The alert volume varies. So does what it means.
Tables
alert_events
Columns: alert_id INTEGER, svc_name TEXT, severity TEXT, status TEXT, fired_at TEXT, ack_by TEXT, resolved TEXT
| alert_id | svc_name | severity | status | fired_at | ack_by | resolved |
|---|---|---|---|---|---|---|
| 129 | payment-api | high | acknowledged | 2026-02-02 01:07:00 | bob | 2026-02-02 03:11:00 |
| 158 | user-svc | medium | resolved | 2026-03-03 02:14:00 | charlie | 2026-03-03 04:22:00 |
| 187 | search-api | low | silenced | 2026-04-04 03:21:00 | dana | NULL |
| 216 | gateway | Critical | firing | 2026-05-05 04:28:00 | NULL | 2026-05-05 06:44:00 |
| 245 | notif-svc | HIGH | acknowledged | 2026-06-06 05:35:00 | alice | 2026-06-06 07:55:00 |
| 274 | db-primary | critical | resolved | 2026-07-07 06:42:00 | bob | NULL |
| 303 | cache-01 | high | silenced | 2026-08-08 07:49:00 | charlie | 2026-08-08 09:17:00 |
| 332 | auth-svc | medium | firing | 2026-09-09 08:56:00 | NULL | 2026-09-09 10:28:00 |
| 361 | payment-api | low | acknowledged | 2026-01-10 09:03:00 | eve | NULL |
| 390 | user-svc | Critical | resolved | 2026-02-11 10:10:00 | alice | 2026-02-11 12:50:00 |
svc_health
Columns: check_id INTEGER, svc_name TEXT, status TEXT, latency REAL, uptime REAL, checked TEXT, region TEXT
| check_id | svc_name | status | latency | uptime | checked | region |
|---|---|---|---|---|---|---|
| 137 | payment-api | degraded | 3.6 | 97.03 | 2026-02-02 01:05:00 | us-west-2 |
| 174 | user-svc | unhealthy | 6.7 | 94.06 | 2026-03-03 02:10:00 | eu-west-1 |
| 211 | search-api | timeout | 9.8 | 91.09 | 2026-04-04 03:15:00 | ap-south-1 |
| 248 | gateway | Healthy | 12.9 | 98.12 | 2026-05-05 04:20:00 | eu-central-1 |
| 285 | notif-svc | DEGRADED | 16 | 95.15 | 2026-06-06 05:25:00 | us-east-1 |
| 322 | ml-serving | healthy | 19.1 | 92.18 | 2026-07-07 06:30:00 | us-west-2 |
| 359 | cache-svc | degraded | NULL | 99.21 | 2026-08-08 07:35:00 | eu-west-1 |
| 396 | queue-worker | unhealthy | 25.3 | 96.24 | 2026-09-09 08:40:00 | ap-south-1 |
| 433 | cron-svc | timeout | 28.4 | 93.27 | 2026-01-10 09:45:00 | eu-central-1 |
| 470 | auth-svc | Healthy | 31.5 | 90.3 | 2026-02-11 10:50:00 | us-east-1 |
Expected output
| severity | avg_alert_count |
|---|---|
| critical | 10 |
| high | 10 |
| low | 5 |
| medium | 5.3 |
Worked solution and explanation
What this really is
This is an average of counts dressed up as alert hygiene. Anyone can count alerts per severity. The candidates who pass first pin down the population being averaged: one row per service and cleaned severity that fired more than once, and only for services the health system knows about. Collapse those two levels into one pass, or let svc_health fan the rows out, and you ship numbers that look reasonable and answer a different question.
Two quieter traps sit in the inner query. The severity has to be cleaned before counting, not after. The year you pull out of fired_at comes back as text, and text never equals the number 2026. Miss that and the report is empty, with no error to tell you why.
Building it
Step 1: Scope to the year, as a number
strftime('%Y', fired_at) returns text such as '2026'. Compared to the number 2026 it matches no row. CAST(... AS INTEGER) puts both sides in the same type, so the year bites before counting and older alerts stay out of every pair.
Step 2: Count each cleaned pair and drop one-offs
GROUP BY svc_name, LOWER(severity) with COUNT(*) AS alert_count, then HAVING COUNT(*) > 1. Lowering inside the grouping lets 'HIGH' and 'high' add up before the gate judges the pair.
Step 3: Gate on a deduplicated membership list
svc_health has a row per health check, so reduce it to one row per svc_name with its own GROUP BY before the INNER JOIN. Now the join removes unmonitored services like 'db-primary' and copies nothing.
Step 4: Average the pairs per severity
Outer GROUP BY combo.severity, then ROUND(AVG(combo.alert_count), 1). Each surviving pair counts once in its severity's mean, however many alerts it holds.
SELECT
combo.severity,
ROUND(AVG(combo.alert_count), 1) AS avg_alert_count
FROM (
SELECT
svc_name,
LOWER(severity) AS severity,
COUNT(*) AS alert_count
FROM alert_events
WHERE CAST(strftime('%Y', fired_at) AS INTEGER) = 2026
GROUP BY svc_name, LOWER(severity)
HAVING COUNT(*) > 1
) combo
INNER JOIN (
SELECT svc_name
FROM svc_health
GROUP BY svc_name
) monitored
ON combo.svc_name = monitored.svc_name
GROUP BY combo.severity- One pass over alerts
SELECT severity, COUNT(*) * 1.0 / COUNT(DISTINCT svc_name) FROM alert_events GROUP BY severityhas nowhere to apply the per-pairHAVING COUNT(*) > 1, so single-alert pairs drag every mean down.- Count pairs, then average
- The inner
GROUP BY svc_name, LOWER(severity)materializes exactly the pairs that qualify; the outerGROUP BY severityaverages that population and nothing else.
Common follow-up questions
- What happens to the averages if you join `svc_health` without reducing it to one row per `svc_name`? (Tests whether the candidate sees that duplication reweights a mean rather than inflating it.)
- Rewrite the year filter so a date-partitioned `alert_events` can skip partitions. (A range on `fired_at` instead of a function wrapped around it, which also removes the text-versus-number question.)
- How would you report the median count per severity instead of the mean? (Reuses the inner pair counts as a building block.)
- A new label 'Sev1' starts appearing alongside 'critical'. Where does that mapping live? (Moving from case cleaning to a reference mapping applied before the inner grouping.)