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.
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
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
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)
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
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
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
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
-- "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
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
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
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
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
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.
You've gone deep on SQL. Now the systems around it: 04 — Data Pipelines →