Drill · Advanced · Solutions Hidden

SQL Problems II — Advanced

Twelve harder problems — as-of joins, interval merging, rolling distinct counts, anomaly detection in SQL. These are the ones that decide a senior screen. Drill mode on, timer running.

🎯 12 problems 🔥 Harder set ⏱ 20-30 min each 🔁 Progress saved locally
0 / 12 practiced
Schema (extends 03)

rentals(rental_id, machine_id, customer_id, started_at, ended_at, rental_type) · price_history(machine_id, price_per_hr, valid_from, valid_to) · instance_state_log(instance_id, state, changed_at) · bids(bid_id, machine_id, customer_id, bid_price, placed_at, won) · usage_events(...) as before.

1As-of price join

Range join · Point-in-time.

Prompt: Price each usage interval at the machine's price that was active when it ran. Prices change over time in price_history (non-overlapping validity windows).

Solution
a1.sql
SELECT u.event_id, u.machine_id, u.started_at,
       u.gpu_seconds / 3600.0 * p.price_per_hr AS cost
FROM usage_events u
JOIN price_history p
  ON p.machine_id = u.machine_id
 AND u.started_at >= p.valid_from
 AND u.started_at <  p.valid_to;

Watch: if windows can overlap you fan out — add a test that windows are disjoint, or pick the latest with QUALIFY ROW_NUMBER() OVER (PARTITION BY u.event_id ORDER BY p.valid_from DESC)=1. Engines with native ASOF JOIN do this directly.

2Merge overlapping rental intervals

Window · Interval merge / "sweep".

Prompt: Per machine, collapse overlapping or back-to-back rental intervals into continuous "busy" periods (to compute true occupied time without double-counting).

Solution — running max end, flag new island
a2.sql
WITH ordered AS (
  SELECT machine_id, started_at, ended_at,
    MAX(ended_at) OVER (PARTITION BY machine_id ORDER BY started_at
         ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS prev_max_end
  FROM rentals
),
flagged AS (
  SELECT *, CASE WHEN prev_max_end IS NULL OR started_at > prev_max_end
                 THEN 1 ELSE 0 END AS new_island
  FROM ordered
),
grouped AS (
  SELECT *, SUM(new_island) OVER (PARTITION BY machine_id ORDER BY started_at) AS grp
  FROM flagged
)
SELECT machine_id, MIN(started_at) AS busy_start, MAX(ended_at) AS busy_end
FROM grouped GROUP BY machine_id, grp;

Idea: a new period starts only when this interval begins after the running max end seen so far. Running-max + flag + running-sum = islands over intervals. Classic and impressive.

3Rolling 30-day active customers

Aggregate · Rolling distinct count.

Prompt: For each day, how many distinct customers were active in the trailing 30 days? (Distinct counts don't compose with a simple window SUM — that's the trap.)

Solution — self-join on date range (and the scale note)
a3.sql
WITH daily AS (
  SELECT DISTINCT customer_id, DATE(started_at) AS day FROM usage_events
),
spine AS (
  SELECT generate_series(DATE '2026-01-01', CURRENT_DATE, INTERVAL '1 day')::date AS day
)
SELECT s.day, COUNT(DISTINCT d.customer_id) AS rolling_30d_active
FROM spine s
JOIN daily d ON d.day > s.day - 30 AND d.day <= s.day
GROUP BY s.day
ORDER BY s.day;

Why not a window: COUNT(DISTINCT) isn't a windowable rolling op in most engines. The range self-join is correct; at scale, switch to mergeable HLL sketches per day and union the trailing 30 (mention this — it's the senior answer).

4Interruptions & time-to-resume

Window · State machine.

Prompt: From instance_state_log (states: running, stopped), count interruptions per instance and the median time from a stop to the next run.

Solution — LEAD to pair stop→next-run
a4.sql
WITH seq AS (
  SELECT instance_id, state, changed_at,
    LEAD(state)      OVER (PARTITION BY instance_id ORDER BY changed_at) AS next_state,
    LEAD(changed_at) OVER (PARTITION BY instance_id ORDER BY changed_at) AS next_at
  FROM instance_state_log
),
interruptions AS (
  SELECT instance_id, changed_at AS stopped_at, next_at AS resumed_at
  FROM seq
  WHERE state = 'stopped' AND next_state = 'running'   -- a true resume
)
SELECT instance_id,
       COUNT(*) AS interruptions,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY resumed_at - stopped_at) AS median_resume
FROM interruptions GROUP BY instance_id;

Watch: a stop with no following run is a permanent kill, not an interruption — the next_state='running' guard excludes it. Very relevant to interruptible-instance analytics.

5Time-boxed funnel

Join · Funnel with windows.

Prompt: Of customers who ran a search, how many placed a bid within 1 hour, and of those, how many had a running instance within 1 hour of the bid? Counts per stage.

Solution — staged EXISTS with time bounds
a5.sql
WITH first_search AS (
  SELECT customer_id, MIN(searched_at) AS t0 FROM searches GROUP BY customer_id
),
bid AS (
  SELECT f.customer_id, MIN(b.placed_at) AS t1
  FROM first_search f
  JOIN bids b ON b.customer_id=f.customer_id
             AND b.placed_at BETWEEN f.t0 AND f.t0 + INTERVAL '1 hour'
  GROUP BY f.customer_id
),
ran AS (
  SELECT bd.customer_id
  FROM bid bd
  JOIN instance_state_log s ON s.customer_id=bd.customer_id AND s.state='running'
             AND s.changed_at BETWEEN bd.t1 AND bd.t1 + INTERVAL '1 hour'
  GROUP BY bd.customer_id
)
SELECT (SELECT COUNT(*) FROM first_search) AS searched,
       (SELECT COUNT(*) FROM bid)          AS bid_1h,
       (SELECT COUNT(*) FROM ran)          AS ran_1h;

Watch: clarify strict ordering (each step after the previous) vs independent. Time-boxing each hop is what makes it a real funnel, not a co-occurrence count.

6Retention curve (Nx)

Window · Cohort rate.

Prompt: For each signup-week cohort, output the % of customers active in week 0..8 after signup (a triangle/retention curve).

Solution — offset + divide by cohort size
a6.sql
WITH cohort AS (
  SELECT customer_id, DATE_TRUNC('week', signed_up_at) AS wk0 FROM customers
),
size AS (SELECT wk0, COUNT(*) n FROM cohort GROUP BY wk0),
act AS (
  SELECT DISTINCT c.wk0,
    FLOOR((DATE_TRUNC('week', u.started_at) - c.wk0) / 7)::int AS wk_offset,
    u.customer_id
  FROM cohort c JOIN usage_events u USING (customer_id)
)
SELECT a.wk0, a.wk_offset,
       COUNT(DISTINCT a.customer_id) AS active,
       ROUND(100.0*COUNT(DISTINCT a.customer_id)/s.n, 1) AS pct
FROM act a JOIN size s ON s.wk0=a.wk0
WHERE a.wk_offset BETWEEN 0 AND 8
GROUP BY a.wk0, a.wk_offset, s.n
ORDER BY a.wk0, a.wk_offset;

Watch: divide by the cohort's own size for a rate; pivoting offset→columns gives the familiar triangle.

7Dedup with conflict resolution

Window · Survivorship.

Prompt: A customer record arrives from 3 sources with conflicting fields. Produce one golden row per customer: most-recent non-null value per field (not just the latest whole row).

Solution — per-column LAST non-null
a7.sql
-- "Carry forward the latest non-null per field" survivorship
SELECT customer_id,
  (ARRAY_AGG(email      ORDER BY updated_at DESC) FILTER (WHERE email      IS NOT NULL))[1] AS email,
  (ARRAY_AGG(country    ORDER BY updated_at DESC) FILTER (WHERE country    IS NOT NULL))[1] AS country,
  (ARRAY_AGG(plan_tier  ORDER BY updated_at DESC) FILTER (WHERE plan_tier  IS NOT NULL))[1] AS plan_tier
FROM customer_sources
GROUP BY customer_id;

Idea: picking the latest whole row loses fields that row left null. Field-level survivorship — newest non-null per column — is the real-world MDM answer. (Engine-specific: BigQuery uses ARRAY_AGG(... IGNORE NULLS LIMIT 1).)

8Sessions with a duration cap

Window · Constrained sessionization.

Prompt: Sessionize events with a 30-min inactivity gap and a hard cap: no session longer than 2 hours (a long continuous stream still splits every 2h).

Solution — gap flag, then cap within run
a8.sql
WITH lagged AS (
  SELECT customer_id, event_ts,
    LAG(event_ts) OVER (PARTITION BY customer_id ORDER BY event_ts) AS prev
  FROM events
),
gapflag AS (
  SELECT *, CASE WHEN prev IS NULL OR event_ts-prev > INTERVAL '30 min' THEN 1 ELSE 0 END AS gap_new
  FROM lagged
),
gapsess AS (
  SELECT *, SUM(gap_new) OVER (PARTITION BY customer_id ORDER BY event_ts) AS gap_sid
  FROM gapflag
)
SELECT *,
  gap_sid::text || '-' ||
  FLOOR(EXTRACT(EPOCH FROM (event_ts -
        MIN(event_ts) OVER (PARTITION BY customer_id, gap_sid))) / 7200)::text AS session_id
FROM gapsess;

Idea: first split on inactivity gaps, then within each gap-session bucket by elapsed-since-start / 2h. Composing two rules is the harder-than-it-looks part.

9Price deciles per GPU type

Window · Distribution.

Prompt: For each GPU type, bucket current listed machines into price deciles and return the boundary price of each decile (for a pricing-competitiveness view).

Solution — NTILE per partition, boundaries
a9.sql
WITH d AS (
  SELECT gpu_type, price_per_hr,
    NTILE(10) OVER (PARTITION BY gpu_type ORDER BY price_per_hr) AS decile
  FROM listings WHERE status = 'available'
)
SELECT gpu_type, decile,
       MIN(price_per_hr) AS decile_low,
       MAX(price_per_hr) AS decile_high,
       COUNT(*)          AS n
FROM d GROUP BY gpu_type, decile
ORDER BY gpu_type, decile;

Alt: PERCENTILE_CONT(ARRAY[0.1,0.5,0.9]) if you only need specific cut points rather than full deciles.

10Z-score anomaly detection

Window · Statistical test in SQL.

Prompt: Flag days where total GPU-hours deviate > 3 standard deviations from the trailing 28-day mean (excluding the day itself).

Solution — trailing mean/stddev window
a10.sql
WITH daily AS (
  SELECT DATE(started_at) AS day, SUM(gpu_seconds)/3600.0 AS gpu_hours
  FROM usage_events GROUP BY 1
),
stats AS (
  SELECT day, gpu_hours,
    AVG(gpu_hours)    OVER w AS mean_28,
    STDDEV(gpu_hours) OVER w AS sd_28
  FROM daily
  WINDOW w AS (ORDER BY day ROWS BETWEEN 28 PRECEDING AND 1 PRECEDING)
)
SELECT day, gpu_hours, mean_28, sd_28,
       (gpu_hours - mean_28) / NULLIF(sd_28,0) AS z
FROM stats
WHERE sd_28 > 0 AND ABS((gpu_hours - mean_28)/sd_28) > 3
ORDER BY day;

Watch: exclude the current row (1 PRECEDING) so a spike doesn't inflate its own baseline. Account for weekly seasonality by comparing like-days if needed (chapter 05b).

11Gap-fill + carry-forward

Spine · LOCF.

Prompt: Produce a continuous daily series of each machine's listed price, carrying the last known price forward over days with no price change.

Solution — spine × machines, LAST_VALUE IGNORE NULLS
a11.sql
WITH spine AS (
  SELECT generate_series(DATE '2026-01-01', CURRENT_DATE, INTERVAL '1 day')::date AS day
),
m AS (SELECT DISTINCT machine_id FROM price_changes),
grid AS (SELECT m.machine_id, s.day FROM m CROSS JOIN spine s),
joined AS (
  SELECT g.machine_id, g.day, pc.new_price
  FROM grid g
  LEFT JOIN price_changes pc
    ON pc.machine_id=g.machine_id AND DATE(pc.changed_at)=g.day
)
SELECT machine_id, day,
  LAST_VALUE(new_price IGNORE NULLS) OVER (
    PARTITION BY machine_id ORDER BY day
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS price_locf
FROM joined;

Idea: build the full machine×day grid, attach changes where they happen, then carry forward the last non-null. The standard recipe for "value as of each day."

12Median clearing bid per hour

Aggregate · Market price discovery.

Prompt: For each GPU type and hour, what was the median winning bid price (the market-clearing price)? Only bids that actually won count.

Solution — filter to winners, percentile by bucket
a12.sql
SELECT m.gpu_type,
       DATE_TRUNC('hour', b.placed_at) AS hour,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.bid_price) AS median_clearing_price,
       COUNT(*) AS winning_bids
FROM bids b
JOIN machines m ON m.machine_id = b.machine_id
WHERE b.won = true
GROUP BY m.gpu_type, DATE_TRUNC('hour', b.placed_at)
ORDER BY gpu_type, hour;

Why it matters: on an auction marketplace the clearing price is the price signal. Add P10/P90 (PERCENTILE_CONT(ARRAY[0.1,0.9])) to show spread, and compare to on-demand list price to quantify interruptible savings.

Next

You've gone deep on SQL. Now the systems around it: 04 — Data Pipelines