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,
(DATE_TRUNC('week', u.started_at)::date - c.wk0::date) / 7 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." IGNORE NULLS works in Snowflake, BigQuery and DuckDB; PostgreSQL rejects it. There, use MAX(new_price) OVER (PARTITION BY machine_id, grp) with grp = COUNT(new_price) OVER (PARTITION BY machine_id ORDER BY 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 →
✓ Check yourself
Answer from memory before opening each one.
-
In problem 2, one machine has rentals 09:00 to 10:00, 09:30 to 09:45, 10:00 to 11:00 and 12:00 to 13:00. What busy periods come back, and why would comparing each row only with the previous row's end time fail?
Show answer
The periods are 09:00 to 11:00 and 12:00 to 13:00; the third rental starts exactly when the running maximum end (10:00) is reached, so it joins the first. Comparing with the previous row's end (09:45) would wrongly start a new period at 10:00, because a short interval nested inside a long one hides the true end. See merging overlapping intervals.
-
A teammate computes rolling 30-day actives by summing each day's distinct customer count over a 30-day window. A customer active on five of the thirty days is in the result how many times, and what is the fix?
Show answer
Five times, so the figure overstates. Distinct counts do not add across days. Count distinct customers over the range directly, with a self-join on the date range, or merge daily HyperLogLog sketches at scale. See rolling 30-day actives.
-
A retention query is correct. Someone removes the DISTINCT from the inner activity CTE and week 4 now reads 140%. Why does a rate above 100% appear?
Show answer
Without DISTINCT, each usage row survives as its own row, so the numerator counts events, not customers. The denominator is still the cohort's customer count. Count distinct customers per cohort and week offset, then divide. See the retention curve.
-
A customer has two source rows. One dated March has email a@example.com and a null country. A later one dated April has a null email and country DE. What does latest-whole-row return, and what does the field-level query return?
Show answer
Latest-whole-row returns a null email with country DE. The field-level query returns a@example.com with DE, taking the newest non-null value for each field. This is the survivorship rule you want when sources leave fields empty. See dedup with conflict resolution.
-
Run problem 8 on events every 20 minutes from 00:00 to 02:20, so no 30-minute gap ever occurs. With the 2-hour cap, how many sessions do you get, and where does the split fall?
Show answer
Two sessions. All eight events share one gap-based group, so the cap alone splits them: elapsed time below 2 hours stays in bucket 0 (00:00 to 01:40), and 02:00 and 02:20 fall in bucket 1. Two layered rules need two layered groupings. See sessions with a duration cap.
-
Problem 10 excludes the current day from its own 28-day baseline. What goes wrong if you include it, and what two other cases make the z-score unreliable?
Show answer
A spike inflates its own mean and standard deviation, so the z-score shrinks and the anomaly can pass unnoticed. The score also breaks on a flat series, where the standard deviation is zero, and on the first days of data, where the window is short. Guard both, and compare like weekdays if weekly seasonality is strong. See z-score anomaly detection.
Takeaway
- Merge intervals against the running maximum of earlier end times, not the previous row's end; a new period starts only when a row begins after that maximum.
- Distinct counts do not add across days; a rolling distinct count needs a range join, or mergeable sketches at scale.
- A retention rate divides distinct active customers by the cohort's own size; counting usage rows inflates it.
- When the latest row has nulls, take the newest non-null value per field instead of the whole latest row.
- Two session rules, such as a gap and a cap, are two layered groupings, not one.
- Keep the current row out of its own anomaly baseline, and guard zero variance and short history.
- A point-in-time join is safe only if validity windows do not overlap.
SQL is one skill among three; the next is moving data reliably, so turn to the pipelines. → Data Pipelines
Further reading
- Window Functions — PostgreSQL docs, 2026. Running max, LEAD and LAG behaviour used in problems 2, 4 and 8.
- Table Expressions — PostgreSQL docs, 2026. Range joins and LATERAL, the basis of problems 1 and 3.
- Aggregate Functions — PostgreSQL docs, 2026. ARRAY_AGG with FILTER for survivorship, and ordered-set percentiles.
- ASOF JOIN — Snowflake docs, 2026. The native form of problem 1.
- FROM and JOIN clauses — DuckDB docs, 2026. As-of joins you can run locally against sample data.
- Approximate aggregate functions — Google Cloud docs, 2026. HLL sketches for the scale answer to problem 3.