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_events: 10 of 200 rows
alert_idsvc_nameseveritystatusfired_atack_byresolved
129payment-apihighacknowledged2026-02-02 01:07:00bob2026-02-02 03:11:00
158user-svcmediumresolved2026-03-03 02:14:00charlie2026-03-03 04:22:00
187search-apilowsilenced2026-04-04 03:21:00danaNULL
216gatewayCriticalfiring2026-05-05 04:28:00NULL2026-05-05 06:44:00
245notif-svcHIGHacknowledged2026-06-06 05:35:00alice2026-06-06 07:55:00
274db-primarycriticalresolved2026-07-07 06:42:00bobNULL
303cache-01highsilenced2026-08-08 07:49:00charlie2026-08-08 09:17:00
332auth-svcmediumfiring2026-09-09 08:56:00NULL2026-09-09 10:28:00
361payment-apilowacknowledged2026-01-10 09:03:00eveNULL
390user-svcCriticalresolved2026-02-11 10:10:00alice2026-02-11 12:50:00

svc_health

Columns: check_id INTEGER, svc_name TEXT, status TEXT, latency REAL, uptime REAL, checked TEXT, region TEXT

svc_health: 10 of 200 rows
check_idsvc_namestatuslatencyuptimecheckedregion
137payment-apidegraded3.697.032026-02-02 01:05:00us-west-2
174user-svcunhealthy6.794.062026-03-03 02:10:00eu-west-1
211search-apitimeout9.891.092026-04-04 03:15:00ap-south-1
248gatewayHealthy12.998.122026-05-05 04:20:00eu-central-1
285notif-svcDEGRADED1695.152026-06-06 05:25:00us-east-1
322ml-servinghealthy19.192.182026-07-07 06:30:00us-west-2
359cache-svcdegradedNULL99.212026-08-08 07:35:00eu-west-1
396queue-workerunhealthy25.396.242026-09-09 08:40:00ap-south-1
433cron-svctimeout28.493.272026-01-10 09:45:00eu-central-1
470auth-svcHealthy31.590.32026-02-11 10:50:00us-east-1

Expected output

First 4 of 4 rows
severityavg_alert_count
critical10
high10
low5
medium5.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.

Two-level aggregation behind a membership gate
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 severity has nowhere to apply the per-pair HAVING 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 outer GROUP BY severity averages that population and nothing else.

Common follow-up questions