Data Engineering/Data Engineering SQL · Part 5 of 6
·43 min read
SQL for Data Engineers: Hard Problems Across Industries
Part 5 of a data engineering SQL practice bank: stand-alone problems from healthcare, IoT, ride-share, telecom, ad tech, logistics, energy, markets and master data, covering interval merging, sweep lines, tiered rating, range and as-of joins, interpolation and survivorship. Every answer is run on real sample data.
The case studies built one pipeline each. This post is a set of stand-alone problems from other industries, chosen because each drills a pattern that the earlier posts didn't: merging intervals, versioned records, time buckets and silence, state compression, sweep lines, tiered pricing, range and as-of joins, interpolation and survivorship. Each one has a trap that a plausible first answer falls into, and the step-throughs show it.
The post covers three areas:
- Intervals and states: healthcare coverage, claims, sensors and machines
- Rating and attribution: telecom tiers and ad clicks
- Operations: logistics, energy, markets and master data
#How to use this post
Each question has a difficulty, the pattern it drills, a small sample of data and the expected output. Try it before you open the dropdowns: Hint nudges you, and Answer has the SQL, a line-by-line explanation and a step-through of the data. The step-through shows the real tables at each stage, with changed rows tinted and labelled: inserted or kept rows in green, removed rows in red, changed rows in amber. Every answer ends with an Engineer check: the grain, what happens if it runs twice, and what NULL does. The first post explains how the answers were run and checked.
#Intervals and states
Problems where the rows describe stretches of time, and the answer is about how they overlap, repeat or go quiet.
#Q73 · Healthcare: merge overlapping coverage periods
Hard · Interval merging
member_eligibility holds coverage spans that overlap and touch. Collapse them into continuous coverage periods per member, treating spans that touch (one ends on the 31st, the next starts on the 1st) as continuous.
Sample data
member_eligibility
member_id | plan_id | start_date | end_date |
|---|---|---|---|
| M1 | P1 | 2025-01-01 | 2025-06-30 |
| M1 | P2 | 2025-03-01 | 2025-03-31 |
| M1 | P2 | 2025-07-01 | 2025-09-30 |
| M1 | P3 | 2025-11-01 | 2025-12-31 |
| M2 | P1 | 2025-02-01 | 2025-04-30 |
| M2 | P1 | 2025-04-15 | 2025-05-31 |
Sample data as SQL (to run it yourself)
CREATE TABLE member_eligibility (member_id VARCHAR, plan_id VARCHAR, start_date DATE, end_date DATE);
INSERT INTO member_eligibility VALUES
('M1', 'P1', DATE '2025-01-01', DATE '2025-06-30'),
('M1', 'P2', DATE '2025-03-01', DATE '2025-03-31'), -- inside the first span
('M1', 'P2', DATE '2025-07-01', DATE '2025-09-30'), -- touches the first span
('M1', 'P3', DATE '2025-11-01', DATE '2025-12-31'), -- after a gap
('M2', 'P1', DATE '2025-02-01', DATE '2025-04-30'),
('M2', 'P1', DATE '2025-04-15', DATE '2025-05-31'); -- overlaps
Expected output
member_id | period_no | coverage_start | coverage_end |
|---|---|---|---|
| M1 | 1 | 2025-01-01 | 2025-09-30 |
| M1 | 2 | 2025-11-01 | 2025-12-31 |
| M2 | 1 | 2025-02-01 | 2025-05-31 |
Hint
Sort by start date. A span starts a new period only if it begins after everything before it has ended. 'Everything before it' is a running maximum, not just the previous row.
Answer
WITH ordered AS (
SELECT *,
MAX(end_date) OVER (PARTITION BY member_id ORDER BY start_date, end_date
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS covered_until
FROM member_eligibility
),
grouped AS (
SELECT *,
SUM(CASE WHEN covered_until IS NULL OR start_date > covered_until + 1 THEN 1 ELSE 0 END)
OVER (PARTITION BY member_id ORDER BY start_date, end_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS period_no
FROM ordered
)
SELECT member_id, period_no, MIN(start_date) AS coverage_start, MAX(end_date) AS coverage_end
FROM grouped
GROUP BY member_id, period_no
ORDER BY member_id, period_no;
covered_untilis the running maximum end date of all earlier spans. It has to be a maximum, notLAG(end_date): M1's March span ends before the January span does, and comparing the July span with March's end would wrongly start a new period.- A new period starts when a span begins more than one day after
covered_until. The+ 1merges touching spans (June 30 then July 1); drop it if touching spans should stay separate. - A running sum of the start flags numbers the periods, and grouping by that number collapses each into one continuous range: the same islands trick as sessionization (post 04, Q57).
| member_id | plan_id | start_date | end_date | covered_until | |
|---|---|---|---|---|---|
| inserted | M1 | P1 | 2025-01-01 | 2025-06-30 | NULL |
| M1 | P2 | 2025-03-01 | 2025-03-31 | 2025-06-30 | |
| M1 | P2 | 2025-07-01 | 2025-09-30 | 2025-06-30 | |
| inserted | M1 | P3 | 2025-11-01 | 2025-12-31 | 2025-09-30 |
| inserted | M2 | P1 | 2025-02-01 | 2025-04-30 | NULL |
| M2 | P1 | 2025-04-15 | 2025-05-31 | 2025-04-30 |
Engineer check. Grain: one row per continuous coverage period. NULL: an open-ended span with a NULL end_date breaks the running maximum; store 9999-12-31 instead.
#Q74 · Healthcare: the latest version of each claim line
Hard · Versioned records
claims stores every adjudication version of every claim line. Keep the latest version of each (claim_id, line_no), drop lines whose latest status is VOID, and return billed and paid totals per claim.
Sample data
claims
claim_id | line_no | version | status | billed_amt | paid_amt |
|---|---|---|---|---|---|
| CL-1 | 1 | 1 | PAID | 200.00 | 150.00 |
| CL-1 | 1 | 2 | PAID | 200.00 | 180.00 |
| CL-1 | 2 | 1 | PAID | 50.00 | 50.00 |
| CL-1 | 2 | 2 | VOID | 50.00 | 0.00 |
| CL-2 | 1 | 1 | VOID | 300.00 | 0.00 |
| CL-2 | 1 | 2 | PAID | 300.00 | 240.00 |
Sample data as SQL (to run it yourself)
CREATE TABLE claims (claim_id VARCHAR, line_no INT, version INT, status VARCHAR, billed_amt DECIMAL(10,2), paid_amt DECIMAL(10,2));
INSERT INTO claims VALUES
('CL-1', 1, 1, 'PAID', 200.00, 150.00), ('CL-1', 1, 2, 'PAID', 200.00, 180.00),
('CL-1', 2, 1, 'PAID', 50.00, 50.00), ('CL-1', 2, 2, 'VOID', 50.00, 0.00),
('CL-2', 1, 1, 'VOID', 300.00, 0.00), ('CL-2', 1, 2, 'PAID', 300.00, 240.00);
Expected output
claim_id | total_billed | total_paid |
|---|---|---|
| CL-1 | 200.00 | 180.00 |
| CL-2 | 300.00 | 240.00 |
Hint
Pick the latest version first, then filter on its status. The other order gives a different answer.
Answer
WITH latest AS (
SELECT *
FROM claims
QUALIFY ROW_NUMBER() OVER (PARTITION BY claim_id, line_no ORDER BY version DESC) = 1
)
SELECT claim_id, SUM(billed_amt) AS total_billed, SUM(paid_amt) AS total_paid
FROM latest
WHERE status <> 'VOID'
GROUP BY claim_id
ORDER BY claim_id;
- Latest version first:
ROW_NUMBER() … ORDER BY version DESC = 1keeps one row per claim line. - Then filter
VOID. Filtering before picking the latest would resurrect earlier versions: CL-1 line 2's latest version is VOID, and filtering first would fall back to its version 1 and count $50 paid that was reversed. CL-2 line 1 was voided then re-adjudicated, and its latest version is PAID. - This "latest version, then the business filter" order applies to any versioned source: orders, policies, contracts.
| claim_id | line_no | version | status | billed_amt | paid_amt | |
|---|---|---|---|---|---|---|
| removed | CL-1 | 1 | 1 | PAID | 200.00 | 150.00 |
| kept | CL-1 | 1 | 2 | PAID | 200.00 | 180.00 |
| removed | CL-1 | 2 | 1 | PAID | 50.00 | 50.00 |
| removed | CL-1 | 2 | 2 | VOID | 50.00 | 0.00 |
| removed | CL-2 | 1 | 1 | VOID | 300.00 | 0.00 |
| kept | CL-2 | 1 | 2 | PAID | 300.00 | 240.00 |
Engineer check. Grain: one row per claim. NULL: a NULL status passes <> 'VOID' as unknown and is dropped; use IS DISTINCT FROM 'VOID' if NULL statuses should count.
#Q75 · IoT: downsample to 5-minute buckets, and find the silent ones
Hard · Time bucketing
sensor_reading streams irregular readings. Return the average value per device per 5-minute bucket between 10:00 and 10:30, and list the buckets in which a device sent nothing.
Sample data
sensor_reading
device_id | reading_ts | value |
|---|---|---|
| D1 | 2025-03-01 10:01 | 20.0 |
| D1 | 2025-03-01 10:03 | 22.0 |
| D1 | 2025-03-01 10:07 | 21.0 |
| D1 | 2025-03-01 10:21 | 25.0 |
| D2 | 2025-03-01 10:02 | 5.0 |
| D2 | 2025-03-01 10:06 | 6.0 |
| D2 | 2025-03-01 10:11 | 7.0 |
| D2 | 2025-03-01 10:16 | 6.5 |
| D2 | 2025-03-01 10:22 | 6.0 |
| D2 | 2025-03-01 10:27 | 5.5 |
Sample data as SQL (to run it yourself)
CREATE TABLE sensor_reading (device_id VARCHAR, reading_ts TIMESTAMP, value DOUBLE);
INSERT INTO sensor_reading VALUES
('D1', TIMESTAMP '2025-03-01 10:01', 20.0), ('D1', TIMESTAMP '2025-03-01 10:03', 22.0),
('D1', TIMESTAMP '2025-03-01 10:07', 21.0), ('D1', TIMESTAMP '2025-03-01 10:21', 25.0),
('D2', TIMESTAMP '2025-03-01 10:02', 5.0), ('D2', TIMESTAMP '2025-03-01 10:06', 6.0),
('D2', TIMESTAMP '2025-03-01 10:11', 7.0), ('D2', TIMESTAMP '2025-03-01 10:16', 6.5),
('D2', TIMESTAMP '2025-03-01 10:22', 6.0), ('D2', TIMESTAMP '2025-03-01 10:27', 5.5);
Expected output
device_id | bucket_start | avg_value | readings |
|---|---|---|---|
| D1 | 2025-03-01 10:00 | 21.0 | 2 |
| D1 | 2025-03-01 10:05 | 21.0 | 1 |
| D1 | 2025-03-01 10:10 | NULL | 0 |
| D1 | 2025-03-01 10:15 | NULL | 0 |
| D1 | 2025-03-01 10:20 | 25.0 | 1 |
| D1 | 2025-03-01 10:25 | NULL | 0 |
| D2 | 2025-03-01 10:00 | 5.0 | 1 |
| D2 | 2025-03-01 10:05 | 6.0 | 1 |
| D2 | 2025-03-01 10:10 | 7.0 | 1 |
| D2 | 2025-03-01 10:15 | 6.5 | 1 |
| D2 | 2025-03-01 10:20 | 6.0 | 1 |
| D2 | 2025-03-01 10:25 | 5.5 | 1 |
Hint
Bucketing gives you the buckets that have data. The silent ones need a list of buckets that should exist.
Answer
In Snowflake:
-- Snowflake buckets timestamps with TIME_SLICE
SELECT device_id, TIME_SLICE(reading_ts, 5, 'MINUTE') AS bucket_start, AVG(value) AS avg_value
FROM sensor_reading
GROUP BY ALL;
The same logic in portable SQL, as run on DuckDB to produce the output above:
WITH buckets AS (
SELECT b AS bucket_start
FROM generate_series(TIMESTAMP '2025-03-01 10:00', TIMESTAMP '2025-03-01 10:25', INTERVAL 5 MINUTE) t(b)
),
expected AS (
SELECT d.device_id, b.bucket_start
FROM (SELECT DISTINCT device_id FROM sensor_reading) d CROSS JOIN buckets b
),
actual AS (
SELECT device_id, time_bucket(INTERVAL 5 MINUTE, reading_ts) AS bucket_start,
ROUND(AVG(value), 2) AS avg_value, COUNT(*) AS readings
FROM sensor_reading
GROUP BY ALL
)
SELECT e.device_id, e.bucket_start, a.avg_value, COALESCE(a.readings, 0) AS readings
FROM expected e
LEFT JOIN actual a USING (device_id, bucket_start)
ORDER BY e.device_id, e.bucket_start;
- Bucketing floors each timestamp to its 5-minute slot:
time_bucketin DuckDB,TIME_SLICEin Snowflake (docs),date_binin Postgres. Averaging per slot downsamples the stream to a fixed grain. - Silence isn't in the data. A device that sent nothing has no rows to group, so a
GROUP BYcan never report it. The expected grid (every device × every bucket) defines what should exist, and aLEFT JOINshows the gaps: D1 was silent from 10:10 to 10:20 and again from 10:25. readings = 0marks a silent bucket explicitly, which is easier to alert on than a NULL average.
| device_id | reading_ts | value |
|---|---|---|
| D1 | 2025-03-01 10:01 | 20 |
| D1 | 2025-03-01 10:03 | 22 |
| D1 | 2025-03-01 10:07 | 21 |
| D1 | 2025-03-01 10:21 | 25 |
| D2 | 2025-03-01 10:02 | 5 |
| D2 | 2025-03-01 10:06 | 6 |
| D2 | 2025-03-01 10:11 | 7 |
| D2 | 2025-03-01 10:16 | 6.5 |
| D2 | 2025-03-01 10:22 | 6 |
| D2 | 2025-03-01 10:27 | 5.5 |
Engineer check. Grain: one row per device per expected bucket. NULL: a silent bucket's average is NULL, which is honest; don't fill it with 0, which is a real sensor value.
#Q76 · IoT: compress a status log into state intervals
Hard · State changes
machine_status logs a reading every few minutes. Collapse runs of the same consecutive status into intervals with a start, an end and a duration, where a state ends when the next state begins (not at its last reading).
Sample data
machine_status
machine_id | status | status_ts |
|---|---|---|
| MX | RUN | 2025-03-01 08:00 |
| MX | RUN | 2025-03-01 08:05 |
| MX | RUN | 2025-03-01 08:10 |
| MX | IDLE | 2025-03-01 08:15 |
| MX | IDLE | 2025-03-01 08:20 |
| MX | RUN | 2025-03-01 08:25 |
| MX | DOWN | 2025-03-01 08:40 |
Sample data as SQL (to run it yourself)
CREATE TABLE machine_status (machine_id VARCHAR, status VARCHAR, status_ts TIMESTAMP);
INSERT INTO machine_status VALUES
('MX', 'RUN', TIMESTAMP '2025-03-01 08:00'), ('MX', 'RUN', TIMESTAMP '2025-03-01 08:05'),
('MX', 'RUN', TIMESTAMP '2025-03-01 08:10'), ('MX', 'IDLE', TIMESTAMP '2025-03-01 08:15'),
('MX', 'IDLE', TIMESTAMP '2025-03-01 08:20'), ('MX', 'RUN', TIMESTAMP '2025-03-01 08:25'),
('MX', 'DOWN', TIMESTAMP '2025-03-01 08:40');
Expected output
machine_id | status | state_start | state_end | minutes |
|---|---|---|---|---|
| MX | RUN | 2025-03-01 08:00 | 2025-03-01 08:15 | 15 |
| MX | IDLE | 2025-03-01 08:15 | 2025-03-01 08:25 | 10 |
| MX | RUN | 2025-03-01 08:25 | 2025-03-01 08:40 | 15 |
| MX | DOWN | 2025-03-01 08:40 | NULL | NULL |
Hint
Mark the rows where the status differs from the previous one, number the runs with a running sum, and then let each run end where the next one starts.
Answer
WITH marked AS (
SELECT *,
CASE WHEN status IS DISTINCT FROM LAG(status) OVER (PARTITION BY machine_id ORDER BY status_ts)
THEN 1 ELSE 0 END AS is_change
FROM machine_status
),
runs AS (
SELECT *,
SUM(is_change) OVER (PARTITION BY machine_id ORDER BY status_ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_no
FROM marked
),
intervals AS (
SELECT machine_id, run_no, status, MIN(status_ts) AS state_start
FROM runs GROUP BY ALL
)
SELECT machine_id, status, state_start,
LEAD(state_start) OVER (PARTITION BY machine_id ORDER BY state_start) AS state_end,
datediff('minute', state_start,
LEAD(state_start) OVER (PARTITION BY machine_id ORDER BY state_start)) AS minutes
FROM intervals
ORDER BY state_start;
is_changeis 1 where the status differs from the previous reading.IS DISTINCT FROMmakes the first row (previous is NULL) count as a change.- A running sum of the change flags gives every row its run number, constant within a stretch of the same status: the islands pattern again, with the change flag as the boundary.
- End at the next start, not the last reading. The first RUN stretch's last reading is 08:10, but the machine was running until it went idle at 08:15. Using
MAX(status_ts)per run would report 10 minutes of running instead of 15, and a one-reading state such as the RUN at 08:25 would last 0 minutes.LEAD(state_start)closes each state when the next begins. - The last state (DOWN) is still in progress, so its end is NULL. Fill it with the reporting time if you need a duration.
| machine_id | status | status_ts |
|---|---|---|
| MX | RUN | 2025-03-01 08:00 |
| MX | RUN | 2025-03-01 08:05 |
| MX | RUN | 2025-03-01 08:10 |
| MX | IDLE | 2025-03-01 08:15 |
| MX | IDLE | 2025-03-01 08:20 |
| MX | RUN | 2025-03-01 08:25 |
| MX | DOWN | 2025-03-01 08:40 |
Engineer check. Grain: one row per state interval. NULL: the current state's end is NULL by design; datediff returns NULL for it.
#Q77 · Ride-share: peak concurrent trips per hour
Hard · Sweep line
For each hour from 08:00 to 11:00, report the maximum number of trips in progress at the same time. A trip is in progress on [start_ts, end_ts). One trip runs through a whole hour without starting or ending in it, and that hour must still show it.
Sample data
trips
trip_id | start_ts | end_ts |
|---|---|---|
| T1 | 2025-03-01 08:05 | 2025-03-01 08:40 |
| T2 | 2025-03-01 08:20 | 2025-03-01 08:50 |
| T3 | 2025-03-01 08:40 | 2025-03-01 09:10 |
| T4 | 2025-03-01 08:55 | 2025-03-01 11:20 |
Sample data as SQL (to run it yourself)
CREATE TABLE trips (trip_id VARCHAR, start_ts TIMESTAMP, end_ts TIMESTAMP);
INSERT INTO trips VALUES
('T1', TIMESTAMP '2025-03-01 08:05', TIMESTAMP '2025-03-01 08:40'),
('T2', TIMESTAMP '2025-03-01 08:20', TIMESTAMP '2025-03-01 08:50'),
('T3', TIMESTAMP '2025-03-01 08:40', TIMESTAMP '2025-03-01 09:10'), -- starts as T1 ends
('T4', TIMESTAMP '2025-03-01 08:55', TIMESTAMP '2025-03-01 11:20'); -- runs through 10:00-11:00
Expected output
hour | peak_concurrent |
|---|---|
| 2025-03-01 08:00 | 2 |
| 2025-03-01 09:00 | 2 |
| 2025-03-01 10:00 | 1 |
Hint
Turn each trip into +1 at its start and −1 at its end; a running sum is the number in progress. Then ask what the running sum is at an hour where no trip starts or ends.
Answer
WITH hours AS (
SELECT h FROM generate_series(TIMESTAMP '2025-03-01 08:00', TIMESTAMP '2025-03-01 10:00', INTERVAL 1 HOUR) t(h)
),
events AS (
SELECT start_ts AS ts, +1 AS delta FROM trips
UNION ALL SELECT end_ts, -1 FROM trips
UNION ALL SELECT h, 0 FROM hours -- a marker at every hour start
),
running AS (
SELECT ts, delta,
SUM(delta) OVER (ORDER BY ts, delta
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS in_progress
FROM events
)
SELECT date_trunc('hour', ts) AS hour, MAX(in_progress) AS peak_concurrent
FROM running
WHERE ts >= TIMESTAMP '2025-03-01 08:00' AND ts < TIMESTAMP '2025-03-01 11:00'
GROUP BY ALL
ORDER BY hour;
- The sweep line: every trip becomes two events, +1 at its start and −1 at its end. Sorted by time, the running sum is the number of trips in progress at each moment, and the peak of an hour is the largest running sum inside it. One sort instead of comparing every trip with every other.
ORDER BY ts, deltaputs −1 before +1 at the same instant, which implements half-open intervals: T1 ending at 08:40 and T3 starting at 08:40 don't overlap. The tie order is the interval rule.- Hour markers (
delta = 0) carry the running sum into every hour. Without them, an hour where nothing starts or ends has no events at all and disappears from the result, even though T4 was in progress for all of 10:00–11:00. Worse, an hour whose only event is an end under-reports: 09:00 would show 1, because the moment of peak concurrency (the hour start, with T3 and T4 both running) has no row. With a marker, 09:00 reads 2 and 10:00 reads 1.
| ts | what | delta | in_progress | |
|---|---|---|---|---|
| look here | 2025-03-01 08:00 | hour marker | 0 | 0 |
| 2025-03-01 08:05 | T1 start | 1 | 1 | |
| 2025-03-01 08:20 | T2 start | 1 | 2 | |
| 2025-03-01 08:40 | T1 end | -1 | 1 | |
| 2025-03-01 08:40 | T3 start | 1 | 2 | |
| 2025-03-01 08:50 | T2 end | -1 | 1 | |
| 2025-03-01 08:55 | T4 start | 1 | 2 | |
| look here | 2025-03-01 09:00 | hour marker | 0 | 2 |
| 2025-03-01 09:10 | T3 end | -1 | 1 | |
| look here | 2025-03-01 10:00 | hour marker | 0 | 1 |
| 2025-03-01 11:20 | T4 end | -1 | 0 |
Engineer check. Grain: one row per hour. NULL: a trip with no end time never subtracts; give in-progress trips the reporting time as their end.
#Q78 · Ride-share: driver utilisation
Hard · Gaps
Per driver per day, compute minutes driving, minutes idle between trips, and utilisation, driving ÷ (driving + idle).
Sample data
trips
trip_id | driver_id | start_ts | end_ts |
|---|---|---|---|
| T1 | DR1 | 2025-03-01 08:00 | 2025-03-01 08:30 |
| T2 | DR1 | 2025-03-01 08:45 | 2025-03-01 09:15 |
| T3 | DR1 | 2025-03-01 10:15 | 2025-03-01 10:45 |
| T4 | DR2 | 2025-03-01 09:00 | 2025-03-01 09:40 |
Sample data as SQL (to run it yourself)
CREATE TABLE trips (trip_id VARCHAR, driver_id VARCHAR, start_ts TIMESTAMP, end_ts TIMESTAMP);
INSERT INTO trips VALUES
('T1', 'DR1', TIMESTAMP '2025-03-01 08:00', TIMESTAMP '2025-03-01 08:30'),
('T2', 'DR1', TIMESTAMP '2025-03-01 08:45', TIMESTAMP '2025-03-01 09:15'),
('T3', 'DR1', TIMESTAMP '2025-03-01 10:15', TIMESTAMP '2025-03-01 10:45'),
('T4', 'DR2', TIMESTAMP '2025-03-01 09:00', TIMESTAMP '2025-03-01 09:40');
Expected output
driver_id | d | driving_min | idle_min | utilisation_pct |
|---|---|---|---|---|
| DR1 | 2025-03-01 | 90 | 75 | 54.5 |
| DR2 | 2025-03-01 | 40 | 0 | 100.0 |
Hint
Idle time is the gap from the previous trip's end to this trip's start, within the same day.
Answer
WITH t AS (
SELECT *, CAST(start_ts AS DATE) AS d,
datediff('minute', start_ts, end_ts) AS drive_min,
LAG(end_ts) OVER (PARTITION BY driver_id, CAST(start_ts AS DATE) ORDER BY start_ts) AS prev_end
FROM trips
)
SELECT driver_id, d,
SUM(drive_min) AS driving_min,
SUM(COALESCE(datediff('minute', prev_end, start_ts), 0)) AS idle_min,
ROUND(100.0 * SUM(drive_min)
/ NULLIF(SUM(drive_min) + SUM(COALESCE(datediff('minute', prev_end, start_ts), 0)), 0), 1) AS utilisation_pct
FROM t
GROUP BY driver_id, d
ORDER BY driver_id, d;
LAG(end_ts)per driver and day gives the previous trip's end; idle is the gap to this trip's start. DR1 waited 15 and then 60 minutes.- The first trip has no previous trip, so its gap is NULL, treated as 0. Time before the first trip and after the last isn't counted, which is the usual convention: utilisation within the working span.
NULLIFguards the division; DR2 has one trip and no idle time, so 100%.- Overlapping trips (a data error) would give negative gaps. Check
prev_end <= start_tsin a quality test rather than silently netting them.
| trip_id | driver_id | start_ts | end_ts |
|---|---|---|---|
| T1 | DR1 | 2025-03-01 08:00 | 2025-03-01 08:30 |
| T2 | DR1 | 2025-03-01 08:45 | 2025-03-01 09:15 |
| T3 | DR1 | 2025-03-01 10:15 | 2025-03-01 10:45 |
| T4 | DR2 | 2025-03-01 09:00 | 2025-03-01 09:40 |
Engineer check. Grain: one row per driver per day. NULL: the first trip's NULL gap becomes 0 explicitly.
#Rating and attribution
Money that depends on running totals, and credit that has to land on exactly one row.
#Q79 · Telecom: tiered rating with a monthly running total
Hard · Tiered pricing
Rate calls per subscriber per month: the first 100 minutes are free, the next 100 cost 0.05 per minute, and anything above 200 costs 0.10. A single day's usage can cross a tier boundary. Return each subscriber's monthly charge.
Sample data
cdr
subscriber_id | call_ts | duration_sec |
|---|---|---|
| S1 | 2025-03-01 09:00 | 3600 |
| S1 | 2025-03-02 09:00 | 4800 |
| S1 | 2025-03-03 09:00 | 6000 |
| S2 | 2025-03-01 10:00 | 3000 |
Sample data as SQL (to run it yourself)
CREATE TABLE cdr (subscriber_id VARCHAR, call_ts TIMESTAMP, duration_sec INT);
INSERT INTO cdr VALUES
('S1', TIMESTAMP '2025-03-01 09:00', 3600), ('S1', TIMESTAMP '2025-03-02 09:00', 4800), -- 60 + 80 min
('S1', TIMESTAMP '2025-03-03 09:00', 6000), -- 100 min
('S2', TIMESTAMP '2025-03-01 10:00', 3000); -- 50 min
Expected output
subscriber_id | month | minutes | charge |
|---|---|---|---|
| S1 | 2025-03-01 | 240.0 | 9.0 |
| S2 | 2025-03-01 | 50.0 | 0.0 |
Hint
Each day occupies a slice of the month's usage ladder, from the minutes used before it to the minutes used after it. Price the part of each slice that falls inside each tier.
Answer
WITH daily AS (
SELECT subscriber_id, CAST(date_trunc('month', call_ts) AS DATE) AS month, CAST(call_ts AS DATE) AS d,
SUM(duration_sec) / 60.0 AS minutes
FROM cdr GROUP BY ALL
),
ladder AS (
SELECT *,
SUM(minutes) OVER w - minutes AS from_min, -- usage before this day
SUM(minutes) OVER w AS to_min -- usage after this day
FROM daily
WINDOW w AS (PARTITION BY subscriber_id, month ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
),
tiers AS (
SELECT *,
GREATEST(0, LEAST(to_min, 200) - GREATEST(from_min, 100)) AS tier2_min,
GREATEST(0, to_min - GREATEST(from_min, 200)) AS tier3_min
FROM ladder
)
SELECT subscriber_id, month,
SUM(minutes) AS minutes,
ROUND(SUM(tier2_min) * 0.05 + SUM(tier3_min) * 0.10, 2) AS charge
FROM tiers
GROUP BY ALL
ORDER BY subscriber_id;
- The ladder: a running total over the month gives each day a slice
[from_min, to_min)of cumulative usage. S1's second day covers minutes 60 to 140. - Clamping a slice to a tier:
LEAST(to_min, cap) − GREATEST(from_min, floor), floored at 0, is the part of the slice inside the tier[floor, cap). S1's second day has 40 minutes in the free tier and 40 in tier 2; its third day has 60 in tier 2 and 40 in tier 3. - S1: tier 2 is 100 minutes (5.00), tier 3 is 40 minutes (4.00), so 9.00. S2 stays in the free tier.
- Why not price each day on its own: a day doesn't know which tier it starts in. Only the running total does.
GREATEST and LEAST are safe here because no argument can be NULL; remember Snowflake's NULL behaviour (post 02, Q27) if one could.
| subscriber_id | call_ts | duration_sec |
|---|---|---|
| S1 | 2025-03-01 09:00 | 3600 |
| S1 | 2025-03-02 09:00 | 4800 |
| S1 | 2025-03-03 09:00 | 6000 |
| S2 | 2025-03-01 10:00 | 3000 |
Engineer check. Grain: one row per subscriber per month. NULL: a NULL duration_sec is skipped by SUM, so that call is free; reject it in quality checks instead.
#Q80 · AdTech: last-touch attribution within 7 days
Hard · Range joins
Credit each conversion to the user's most recent impression in the 7 days before it. A conversion with no impression in that window stays unattributed. Each conversion is credited at most once.
Sample data
impressions
user_id | campaign_id | imp_ts |
|---|---|---|
| u1 | C-SPRING | 2025-03-01 09:00 |
| u1 | C-RETARGET | 2025-03-05 18:00 |
| u2 | C-SPRING | 2025-02-20 09:00 |
| u3 | C-VIDEO | 2025-03-06 12:00 |
| u3 | C-SPRING | 2025-03-06 12:00 |
conversions
conversion_id | user_id | conv_ts | value |
|---|---|---|---|
| CV1 | u1 | 2025-03-06 10:00 | 80.00 |
| CV2 | u2 | 2025-03-06 10:00 | 50.00 |
| CV3 | u3 | 2025-03-07 09:00 | 30.00 |
Sample data as SQL (to run it yourself)
CREATE TABLE impressions (user_id VARCHAR, campaign_id VARCHAR, imp_ts TIMESTAMP);
INSERT INTO impressions VALUES
('u1', 'C-SPRING', TIMESTAMP '2025-03-01 09:00'), ('u1', 'C-RETARGET', TIMESTAMP '2025-03-05 18:00'),
('u2', 'C-SPRING', TIMESTAMP '2025-02-20 09:00'),
('u3', 'C-VIDEO', TIMESTAMP '2025-03-06 12:00'), ('u3', 'C-SPRING', TIMESTAMP '2025-03-06 12:00');
CREATE TABLE conversions (conversion_id VARCHAR, user_id VARCHAR, conv_ts TIMESTAMP, value DECIMAL(10,2));
INSERT INTO conversions VALUES
('CV1', 'u1', TIMESTAMP '2025-03-06 10:00', 80.00),
('CV2', 'u2', TIMESTAMP '2025-03-06 10:00', 50.00),
('CV3', 'u3', TIMESTAMP '2025-03-07 09:00', 30.00);
Expected output
conversion_id | user_id | value | attributed_campaign | imp_ts |
|---|---|---|---|---|
| CV1 | u1 | 80.00 | C-RETARGET | 2025-03-05 18:00 |
| CV2 | u2 | 50.00 | NULL | NULL |
| CV3 | u3 | 30.00 | C-SPRING | 2025-03-06 12:00 |
Hint
Join each conversion to the impressions in its window, then keep exactly one per conversion. What breaks the tie when two impressions share a timestamp?
Answer
SELECT c.conversion_id, c.user_id, c.value,
i.campaign_id AS attributed_campaign, i.imp_ts
FROM conversions c
LEFT JOIN impressions i
ON i.user_id = c.user_id
AND i.imp_ts <= c.conv_ts
AND i.imp_ts > c.conv_ts - INTERVAL 7 DAY
QUALIFY ROW_NUMBER() OVER (PARTITION BY c.conversion_id
ORDER BY i.imp_ts DESC NULLS LAST, i.campaign_id) = 1
ORDER BY c.conversion_id;
- The range join pairs each conversion with that user's impressions in
(conv − 7 days, conv]. QUALIFY ROW_NUMBER() … = 1per conversion keeps the most recent one, so each conversion is credited exactly once and revenue isn't double-counted across campaigns.- The tiebreak: u3 saw two campaigns at the same second. Without
campaign_idin theORDER BY, which one wins can change between runs, and so would the campaign report. Any deterministic rule is better than none; agree it with the business. LEFT JOINkeeps CV2: u2's only impression is 14 days old, so the conversion stays, unattributed, with a NULL campaign. An inner join would drop $50 of revenue from the report.- Engines with an as-of join (Q85) can find the latest impression directly, then check it's within 7 days.
| user_id | campaign_id | imp_ts |
|---|---|---|
| u1 | C-SPRING | 2025-03-01 09:00 |
| u1 | C-RETARGET | 2025-03-05 18:00 |
| u2 | C-SPRING | 2025-02-20 09:00 |
| u3 | C-VIDEO | 2025-03-06 12:00 |
| u3 | C-SPRING | 2025-03-06 12:00 |
| conversion_id | user_id | conv_ts | value |
|---|---|---|---|
| CV1 | u1 | 2025-03-06 10:00 | 80.00 |
| CV2 | u2 | 2025-03-06 10:00 | 50.00 |
| CV3 | u3 | 2025-03-07 09:00 | 30.00 |
Engineer check. Grain: one row per conversion. NULL: unattributed conversions have NULL campaigns; report them as their own bucket.
#Q81 · AdTech: click-through rate from two duplicated streams
Hard · Join semantics
Impressions and clicks are both delivered at least once. Compute click-through rate per campaign, clicks ÷ impressions, and separately count orphan clicks whose impression doesn't exist.
Sample data
impressions
imp_id | campaign_id |
|---|---|
| I1 | C-A |
| I2 | C-A |
| I2 | C-A |
| I3 | C-A |
| I4 | C-B |
clicks
click_id | imp_id |
|---|---|
| K1 | I1 |
| K1 | I1 |
| K2 | I2 |
| K3 | I9 |
Sample data as SQL (to run it yourself)
CREATE TABLE impressions (imp_id VARCHAR, campaign_id VARCHAR);
INSERT INTO impressions VALUES ('I1', 'C-A'), ('I2', 'C-A'), ('I2', 'C-A'), ('I3', 'C-A'), ('I4', 'C-B');
CREATE TABLE clicks (click_id VARCHAR, imp_id VARCHAR);
INSERT INTO clicks VALUES ('K1', 'I1'), ('K1', 'I1'), ('K2', 'I2'), ('K3', 'I9');
Expected output
campaign_id | impressions | clicks | ctr_pct | orphan_clicks_all_campaigns |
|---|---|---|---|---|
| C-A | 3 | 2 | 66.7 | 1 |
| C-B | 1 | 0 | 0.0 | 1 |
Hint
Deduplicate both streams by their own ids before joining, and keep impressions without clicks.
Answer
WITH imp AS (SELECT DISTINCT imp_id, campaign_id FROM impressions),
clk AS (SELECT DISTINCT click_id, imp_id FROM clicks)
SELECT i.campaign_id,
COUNT(DISTINCT i.imp_id) AS impressions,
COUNT(DISTINCT c.click_id) AS clicks,
ROUND(100.0 * COUNT(DISTINCT c.click_id) / COUNT(DISTINCT i.imp_id), 1) AS ctr_pct,
(SELECT COUNT(*) FROM clk WHERE imp_id NOT IN (SELECT imp_id FROM imp)) AS orphan_clicks_all_campaigns
FROM imp i
LEFT JOIN clk c ON c.imp_id = i.imp_id
GROUP BY i.campaign_id
ORDER BY i.campaign_id;
- Deduplicate both sides first. Duplicate impression I2 inflates the denominator, and duplicate click K1 inflates the numerator. Deduplicating after the join can't undo the fan-out a duplicate causes.
LEFT JOINfrom impressions keeps impressions nobody clicked; they're most of the denominator.- Orphan clicks (K3 points at an impression that never arrived) can't be credited to a campaign. Counting them separately turns a silent loss into a data-quality signal: bots, lost impression logs or a key mismatch.
NOT INis safe here only becauseimp_idis never NULL in the deduplicated set; with a nullable key, useNOT EXISTS(post 01, Q14).
| imp_id | campaign_id |
|---|---|
| I1 | C-A |
| I2 | C-A |
| I2 | C-A |
| I3 | C-A |
| I4 | C-B |
| click_id | imp_id |
|---|---|
| K1 | I1 |
| K1 | I1 |
| K2 | I2 |
| K3 | I9 |
Engineer check. Grain: one row per campaign. NULL: a click with a NULL imp_id matches no impression, and NULL NOT IN (…) is unknown, so the orphan count silently skips it too. Count clicks with NULL keys as their own quality metric.
#Operations and master data
Out-of-order events, sparse data, irregular reads, ticking markets and conflicting sources.
#Q82 · Logistics: SLA breaches from out-of-order events
Hard · Event pivots
Shipment events (PICKED, IN_TRANSIT, DELIVERED) arrive out of order and sometimes twice. Per shipment, return the pick-up and delivery times, the transit hours and whether the lane's SLA was breached. Shipments not yet delivered are reported as open, not dropped.
Sample data
shipment_event
shipment_id | event_type | event_ts |
|---|---|---|
| SH1 | DELIVERED | 2025-03-03 10:00 |
| SH1 | PICKED | 2025-03-01 09:00 |
| SH1 | PICKED | 2025-03-01 09:00 |
| SH2 | PICKED | 2025-03-01 09:00 |
| SH2 | DELIVERED | 2025-03-02 20:00 |
| SH3 | PICKED | 2025-03-02 08:00 |
| SH3 | IN_TRANSIT | 2025-03-02 12:00 |
lane_sla
origin | dest | sla_hours |
|---|---|---|
| PUNE | DELHI | 48 |
| PUNE | GOA | 24 |
Sample data as SQL (to run it yourself)
CREATE TABLE shipment (shipment_id VARCHAR, origin VARCHAR, dest VARCHAR);
INSERT INTO shipment VALUES ('SH1', 'PUNE', 'DELHI'), ('SH2', 'PUNE', 'DELHI'), ('SH3', 'PUNE', 'GOA');
CREATE TABLE lane_sla (origin VARCHAR, dest VARCHAR, sla_hours INT);
INSERT INTO lane_sla VALUES ('PUNE', 'DELHI', 48), ('PUNE', 'GOA', 24);
CREATE TABLE shipment_event (shipment_id VARCHAR, event_type VARCHAR, event_ts TIMESTAMP);
INSERT INTO shipment_event VALUES
('SH1', 'DELIVERED', TIMESTAMP '2025-03-03 10:00'), ('SH1', 'PICKED', TIMESTAMP '2025-03-01 09:00'),
('SH1', 'PICKED', TIMESTAMP '2025-03-01 09:00'),
('SH2', 'PICKED', TIMESTAMP '2025-03-01 09:00'), ('SH2', 'DELIVERED', TIMESTAMP '2025-03-02 20:00'),
('SH3', 'PICKED', TIMESTAMP '2025-03-02 08:00'), ('SH3', 'IN_TRANSIT', TIMESTAMP '2025-03-02 12:00');
Expected output
shipment_id | picked_ts | delivered_ts | transit_hours | sla_hours | sla_status |
|---|---|---|---|---|---|
| SH1 | 2025-03-01 09:00 | 2025-03-03 10:00 | 49 | 48 | BREACHED |
| SH2 | 2025-03-01 09:00 | 2025-03-02 20:00 | 35 | 48 | MET |
| SH3 | 2025-03-02 08:00 | NULL | NULL | 24 | OPEN |
Hint
Pivot by event type, not by arrival order, and decide what an undelivered shipment's status is before you compare with the SLA.
Answer
WITH milestones AS (
SELECT shipment_id,
MIN(CASE WHEN event_type = 'PICKED' THEN event_ts END) AS picked_ts,
MIN(CASE WHEN event_type = 'DELIVERED' THEN event_ts END) AS delivered_ts
FROM shipment_event
GROUP BY shipment_id
)
SELECT m.shipment_id, m.picked_ts, m.delivered_ts,
datediff('hour', m.picked_ts, m.delivered_ts) AS transit_hours,
l.sla_hours,
CASE WHEN m.delivered_ts IS NULL THEN 'OPEN'
WHEN datediff('hour', m.picked_ts, m.delivered_ts) > l.sla_hours THEN 'BREACHED'
ELSE 'MET' END AS sla_status
FROM milestones m
JOIN shipment s USING (shipment_id)
JOIN lane_sla l ON l.origin = s.origin AND l.dest = s.dest
ORDER BY m.shipment_id;
- Pivot by type with
MIN(CASE …): each milestone's time comes from the event's own timestamp, so the order events arrived in doesn't matter, and the duplicate PICKED collapses. - The SLA comes from the lane, so join shipment → lane before comparing.
OPENbefore the comparison: SH3 hasn't been delivered, so its transit time is NULL, andNULL > 24is unknown. Without the first branch it would fall intoELSEand be reported as MET, a shipment that hasn't arrived counted as on time. Ordering theCASEso the NULL case is handled first avoids that.
| shipment_id | event_type | event_ts |
|---|---|---|
| SH1 | DELIVERED | 2025-03-03 10:00 |
| SH1 | PICKED | 2025-03-01 09:00 |
| SH1 | PICKED | 2025-03-01 09:00 |
| SH2 | PICKED | 2025-03-01 09:00 |
| SH2 | DELIVERED | 2025-03-02 20:00 |
| SH3 | PICKED | 2025-03-02 08:00 |
| SH3 | IN_TRANSIT | 2025-03-02 12:00 |
| origin | dest | sla_hours |
|---|---|---|
| PUNE | DELHI | 48 |
| PUNE | GOA | 24 |
Engineer check. Grain: one row per shipment. NULL: an undelivered shipment's NULL comparison is the trap; the first CASE branch handles it.
#Q83 · Logistics: days of cover from a true 7-day average
Hard · Moving averages
sales_daily has no row on days with no sales. Compute each SKU's average daily demand over the 7 calendar days up to 2025-03-07, its days of cover (on_hand ÷ average demand), and a reorder flag when cover is under 14 days. A SKU with no recent demand has infinite cover, not an error.
Sample data
sales_daily
sku | sale_date | units |
|---|---|---|
| A | 2025-03-01 | 10 |
| A | 2025-03-02 | 12 |
| A | 2025-03-03 | 8 |
| A | 2025-03-04 | 10 |
| A | 2025-03-05 | 9 |
| A | 2025-03-06 | 11 |
| A | 2025-03-07 | 10 |
| B | 2025-03-01 | 14 |
| B | 2025-03-07 | 14 |
| C | 2025-02-01 | 5 |
inventory
sku | on_hand |
|---|---|
| A | 100 |
| B | 70 |
| C | 40 |
Sample data as SQL (to run it yourself)
CREATE TABLE sales_daily (sku VARCHAR, sale_date DATE, units INT);
INSERT INTO sales_daily VALUES
('A', DATE '2025-03-01', 10), ('A', DATE '2025-03-02', 12), ('A', DATE '2025-03-03', 8),
('A', DATE '2025-03-04', 10), ('A', DATE '2025-03-05', 9), ('A', DATE '2025-03-06', 11),
('A', DATE '2025-03-07', 10),
('B', DATE '2025-03-01', 14), ('B', DATE '2025-03-07', 14),
('C', DATE '2025-02-01', 5);
CREATE TABLE inventory (sku VARCHAR, on_hand INT);
INSERT INTO inventory VALUES ('A', 100), ('B', 70), ('C', 40);
Expected output
sku | on_hand | avg_daily_demand | days_of_cover | reorder |
|---|---|---|---|---|
| A | 100 | 10.0 | 10.0 | TRUE |
| B | 70 | 4.0 | 17.5 | FALSE |
| C | 40 | 0.0 | NULL | FALSE |
Hint
If a SKU sold on 2 of the last 7 days, how many days should you divide by?
Answer
WITH demand AS (
SELECT sku, SUM(units) / 7.0 AS avg_daily_demand -- divide by calendar days, not rows
FROM sales_daily
WHERE sale_date BETWEEN DATE '2025-03-07' - 6 AND DATE '2025-03-07'
GROUP BY sku
)
SELECT i.sku, i.on_hand,
ROUND(COALESCE(d.avg_daily_demand, 0), 2) AS avg_daily_demand,
ROUND(i.on_hand / NULLIF(d.avg_daily_demand, 0), 1) AS days_of_cover,
COALESCE(i.on_hand / NULLIF(d.avg_daily_demand, 0) < 14, FALSE) AS reorder
FROM inventory i
LEFT JOIN demand d USING (sku)
ORDER BY i.sku;
- Divide by calendar days. B sold 14 units on two of the seven days: its true demand is 28 ÷ 7 = 4 per day. The obvious window function,
AVG(units) OVER (ORDER BY sale_date ROWS 6 PRECEDING), averages the last seven rows, which for B are spread over weeks, and reports 14 per day, three and a half times too much (step 2). That B would be flagged for an urgent reorder it doesn't need. - Infinite cover: C has sold nothing in the window, so
NULLIF(…, 0)makes the division NULL rather than an error, and the reorder flag isCOALESCEd to FALSE. - For a rolling version over many days, use a calendar spine (post 04, Q62) or a
RANGEframe of 6 days divided by 7, never aROWSframe over sparse data.
| sku | days_with_sales | units | per_calendar_day | per_row_avg | |
|---|---|---|---|---|---|
| A | 7 | 70 | 10 | 10 | |
| look here | B | 2 | 28 | 4 | 14 |
Engineer check. Grain: one row per SKU. NULL: no demand gives NULL cover (infinite) and an explicit FALSE flag.
#Q84 · Energy: estimate missing meter reads
Hard · Interpolation
meter_read holds cumulative kWh read at irregular times, sometimes twice in a day and sometimes not for days. For every day from 1 to 6 March, return the day's end-of-day reading, estimating missing days by linear interpolation between the surrounding actual reads.
Sample data
meter_read
meter_id | read_ts | cumulative_kwh |
|---|---|---|
| MT1 | 2025-03-01 08:00 | 1000.0 |
| MT1 | 2025-03-01 20:00 | 1010.0 |
| MT1 | 2025-03-02 20:00 | 1030.0 |
| MT1 | 2025-03-05 20:00 | 1090.0 |
| MT1 | 2025-03-06 20:00 | 1100.0 |
Sample data as SQL (to run it yourself)
CREATE TABLE meter_read (meter_id VARCHAR, read_ts TIMESTAMP, cumulative_kwh DOUBLE);
INSERT INTO meter_read VALUES
('MT1', TIMESTAMP '2025-03-01 08:00', 1000.0), ('MT1', TIMESTAMP '2025-03-01 20:00', 1010.0),
('MT1', TIMESTAMP '2025-03-02 20:00', 1030.0), ('MT1', TIMESTAMP '2025-03-05 20:00', 1090.0),
('MT1', TIMESTAMP '2025-03-06 20:00', 1100.0);
Expected output
meter_id | day | kwh | is_estimated |
|---|---|---|---|
| MT1 | 2025-03-01 | 1010.0 | FALSE |
| MT1 | 2025-03-02 | 1030.0 | FALSE |
| MT1 | 2025-03-03 | 1050.0 | TRUE |
| MT1 | 2025-03-04 | 1070.0 | TRUE |
| MT1 | 2025-03-05 | 1090.0 | FALSE |
| MT1 | 2025-03-06 | 1100.0 | FALSE |
Hint
Reduce each day to one actual reading first. Then each missing day needs the nearest actual before it and after it.
Answer
WITH days AS (
SELECT CAST(d AS DATE) AS day FROM generate_series(DATE '2025-03-01', DATE '2025-03-06', INTERVAL 1 DAY) t(d)
),
daily_actual AS ( -- the LAST read of each day
SELECT meter_id, CAST(read_ts AS DATE) AS day, cumulative_kwh
FROM meter_read
QUALIFY ROW_NUMBER() OVER (PARTITION BY meter_id, CAST(read_ts AS DATE) ORDER BY read_ts DESC) = 1
),
grid AS (
SELECT m.meter_id, days.day, a.cumulative_kwh AS actual
FROM (SELECT DISTINCT meter_id FROM meter_read) m CROSS JOIN days
LEFT JOIN daily_actual a ON a.meter_id = m.meter_id AND a.day = days.day
),
bracketed AS (
SELECT *,
LAST_VALUE(actual IGNORE NULLS) OVER prev AS prev_val,
LAST_VALUE(CASE WHEN actual IS NOT NULL THEN day END IGNORE NULLS) OVER prev AS prev_day,
FIRST_VALUE(actual IGNORE NULLS) OVER next AS next_val,
FIRST_VALUE(CASE WHEN actual IS NOT NULL THEN day END IGNORE NULLS) OVER next AS next_day
FROM grid
WINDOW prev AS (PARTITION BY meter_id ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
next AS (PARTITION BY meter_id ORDER BY day ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
)
SELECT meter_id, day,
ROUND(COALESCE(actual,
prev_val + (next_val - prev_val) * (day - prev_day) / (next_day - prev_day)), 1) AS kwh,
actual IS NULL AS is_estimated
FROM bracketed
ORDER BY day;
- One reading per day first. 1 March has two reads; joining both to the calendar would give 1 March two rows. Keeping the last read of the day matches "end-of-day reading".
- The spine gives every day a row; missing days have a NULL
actual. LAST_VALUE(… IGNORE NULLS)over the rows up to the current one finds the previous actual read;FIRST_VALUE(… IGNORE NULLS)over the rows from the current one finds the next. The same trick on the date gives how far apart they are.- Linear interpolation:
prev + (next − prev) × (days since prev ÷ days between). 3 March is one day into a three-day gap from 1,030 to 1,090, so 1,050. is_estimatedflags the synthetic values. Billing and analytics must be able to tell measured from estimated.- Days before the first read or after the last have no bracket, so they stay NULL rather than being extrapolated.
| meter_id | read_ts | cumulative_kwh |
|---|---|---|
| MT1 | 2025-03-01 08:00 | 1000 |
| MT1 | 2025-03-01 20:00 | 1010 |
| MT1 | 2025-03-02 20:00 | 1030 |
| MT1 | 2025-03-05 20:00 | 1090 |
| MT1 | 2025-03-06 20:00 | 1100 |
Engineer check. Grain: one row per meter per day. NULL: unbracketed days stay NULL on purpose; IGNORE NULLS is what lets the window skip the gaps.
#Q85 · Markets: the prevailing quote for each trade
Hard · As-of joins
Enrich each trade with the prevailing quote: the latest quote for the same symbol at or before the trade's time. A trade before the first quote of its symbol must still appear.
Sample data
trades
trade_id | symbol | trade_ts | qty |
|---|---|---|---|
| TR1 | ACME | 2025-03-03 09:30:05 | 100 |
| TR2 | ACME | 2025-03-03 09:30:20 | 50 |
| TR3 | BOLT | 2025-03-03 09:30:01 | 10 |
| TR4 | ACME | 2025-03-03 09:30:10 | 70 |
quotes
symbol | quote_ts | bid | ask |
|---|---|---|---|
| ACME | 2025-03-03 09:30 | 10.0 | 10.02 |
| ACME | 2025-03-03 09:30:10 | 10.01 | 10.03 |
| ACME | 2025-03-03 09:30:15 | 10.05 | 10.07 |
| BOLT | 2025-03-03 09:30:02 | 5.0 | 5.01 |
Sample data as SQL (to run it yourself)
CREATE TABLE trades (trade_id VARCHAR, symbol VARCHAR, trade_ts TIMESTAMP, qty INT);
INSERT INTO trades VALUES
('TR1', 'ACME', TIMESTAMP '2025-03-03 09:30:05', 100), ('TR2', 'ACME', TIMESTAMP '2025-03-03 09:30:20', 50),
('TR3', 'BOLT', TIMESTAMP '2025-03-03 09:30:01', 10), ('TR4', 'ACME', TIMESTAMP '2025-03-03 09:30:10', 70);
CREATE TABLE quotes (symbol VARCHAR, quote_ts TIMESTAMP, bid DOUBLE, ask DOUBLE);
INSERT INTO quotes VALUES
('ACME', TIMESTAMP '2025-03-03 09:30:00', 10.00, 10.02), ('ACME', TIMESTAMP '2025-03-03 09:30:10', 10.01, 10.03),
('ACME', TIMESTAMP '2025-03-03 09:30:15', 10.05, 10.07), ('BOLT', TIMESTAMP '2025-03-03 09:30:02', 5.00, 5.01);
Expected output
trade_id | symbol | trade_ts | quote_ts | bid | ask |
|---|---|---|---|---|---|
| TR1 | ACME | 2025-03-03 09:30:05 | 2025-03-03 09:30 | 10.0 | 10.02 |
| TR2 | ACME | 2025-03-03 09:30:20 | 2025-03-03 09:30:15 | 10.05 | 10.07 |
| TR3 | BOLT | 2025-03-03 09:30:01 | NULL | NULL | NULL |
| TR4 | ACME | 2025-03-03 09:30:10 | 2025-03-03 09:30:10 | 10.01 | 10.03 |
Hint
An equality join on the timestamp almost never matches. You need the latest row on one side that is not after the row on the other.
Answer
In Snowflake:
SELECT t.trade_id, t.symbol, t.trade_ts, q.quote_ts, q.bid, q.ask
FROM trades t
ASOF JOIN quotes q
MATCH_CONDITION (t.trade_ts >= q.quote_ts)
ON t.symbol = q.symbol;
The same logic in portable SQL, as run on DuckDB to produce the output above:
SELECT t.trade_id, t.symbol, t.trade_ts, q.quote_ts, q.bid, q.ask
FROM trades t
ASOF LEFT JOIN quotes q
ON q.symbol = t.symbol
AND t.trade_ts >= q.quote_ts
ORDER BY t.trade_id;
- An as-of join matches each trade to the quote with the greatest
quote_tsthat is<=the trade time, per symbol. DuckDB writes the inequality insideON(docs); Snowflake puts it inMATCH_CONDITION(docs). >=includes a quote at the same instant: TR4 at 09:30:10 matches the quote from 09:30:10 exactly. Use>if a simultaneous quote shouldn't count.- The left join keeps TR3: BOLT's first quote comes after the trade, so there's no prevailing quote and bid/ask are NULL.
- Why not an equality join: quotes and trades tick at different instants, so
quote_ts = trade_tsmatches almost nothing. Without an as-of join, the portable alternative is a range join plusQUALIFY ROW_NUMBER() … ORDER BY quote_ts DESC = 1(Q80), which is far more expensive on large tables.
| trade_id | symbol | trade_ts | qty |
|---|---|---|---|
| TR1 | ACME | 2025-03-03 09:30:05 | 100 |
| TR2 | ACME | 2025-03-03 09:30:20 | 50 |
| TR3 | BOLT | 2025-03-03 09:30:01 | 10 |
| TR4 | ACME | 2025-03-03 09:30:10 | 70 |
| symbol | quote_ts | bid | ask |
|---|---|---|---|
| ACME | 2025-03-03 09:30 | 10 | 10.02 |
| ACME | 2025-03-03 09:30:10 | 10.01 | 10.03 |
| ACME | 2025-03-03 09:30:15 | 10.05 | 10.07 |
| BOLT | 2025-03-03 09:30:02 | 5 | 5.01 |
Engineer check. Grain: one row per trade. NULL: trades before any quote have NULL bid and ask.
#Q86 · Master data: build the golden record field by field
Hard · Survivorship
customer_source has records for the same real customer from three systems (same match_key). Build one golden record per match_key where each field takes its value from the most complete record that has a non-NULL value for it, breaking ties by the most recent updated_at.
Sample data
customer_source
source_system | match_key | full_name | email | phone | completeness_score | updated_at |
|---|---|---|---|---|---|---|
| CRM | MK-1 | Ana Lopez | ana@example.com | NULL | 90 | 2025-02-01 09:00 |
| Billing | MK-1 | A. Lopez | ana.l@work.com | +1 555 0101 | 70 | 2025-03-01 09:00 |
| Support | MK-1 | Ana M Lopez | NULL | +1 555 0199 | 70 | 2025-03-10 09:00 |
| CRM | MK-2 | Raj Patel | raj@example.com | +91 98000 | 80 | 2025-01-15 09:00 |
Sample data as SQL (to run it yourself)
CREATE TABLE customer_source (source_system VARCHAR, match_key VARCHAR, full_name VARCHAR, email VARCHAR, phone VARCHAR,
completeness_score INT, updated_at TIMESTAMP);
INSERT INTO customer_source VALUES
('CRM', 'MK-1', 'Ana Lopez', 'ana@example.com', NULL, 90, TIMESTAMP '2025-02-01 09:00'),
('Billing', 'MK-1', 'A. Lopez', 'ana.l@work.com', '+1 555 0101', 70, TIMESTAMP '2025-03-01 09:00'),
('Support', 'MK-1', 'Ana M Lopez', NULL, '+1 555 0199', 70, TIMESTAMP '2025-03-10 09:00'),
('CRM', 'MK-2', 'Raj Patel', 'raj@example.com', '+91 98000', 80, TIMESTAMP '2025-01-15 09:00');
Expected output
match_key | full_name | email | phone | sources |
|---|---|---|---|---|
| MK-1 | Ana Lopez | ana@example.com | +1 555 0199 | Billing, CRM, Support |
| MK-2 | Raj Patel | raj@example.com | +91 98000 | CRM |
Hint
Picking the best row isn't enough: the best row can be missing a field another row has.
Answer
In Snowflake:
SELECT match_key,
ARRAY_AGG(full_name) WITHIN GROUP (ORDER BY completeness_score DESC, updated_at DESC)[0]::STRING AS full_name,
ARRAY_AGG(email) WITHIN GROUP (ORDER BY completeness_score DESC, updated_at DESC)[0]::STRING AS email,
ARRAY_AGG(phone) WITHIN GROUP (ORDER BY completeness_score DESC, updated_at DESC)[0]::STRING AS phone
FROM customer_source
GROUP BY match_key;
-- Snowflake's ARRAY_AGG leaves NULL values out, so [0] is the best non-NULL value.
The same logic in portable SQL, as run on DuckDB to produce the output above:
SELECT match_key,
(array_agg(full_name ORDER BY completeness_score DESC, updated_at DESC) FILTER (WHERE full_name IS NOT NULL))[1] AS full_name,
(array_agg(email ORDER BY completeness_score DESC, updated_at DESC) FILTER (WHERE email IS NOT NULL))[1] AS email,
(array_agg(phone ORDER BY completeness_score DESC, updated_at DESC) FILTER (WHERE phone IS NOT NULL))[1] AS phone,
string_agg(DISTINCT source_system, ', ' ORDER BY source_system) AS sources
FROM customer_source
GROUP BY match_key
ORDER BY match_key;
- Field-level survivorship: each field is ranked independently. The CRM record is the most complete, so it wins the name and email, but it has no phone; the phone comes from the best record that has one. Between Billing and Support, both scored 70, the more recent Support record wins.
FILTER (WHERE field IS NOT NULL)removes NULLs before ranking, so a NULL from the top record never wins. Snowflake'sARRAY_AGGalready omits NULLs (docs), and its arrays are 0-indexed where DuckDB's are 1-indexed.- Picking one whole row (
QUALIFY ROW_NUMBER() … = 1) would give MK-1 a NULL phone even though two systems know it. - Keep lineage:
sourcesrecords which systems contributed. Real MDM also stores which source won each field, so a steward can explain or override it.
| source_system | match_key | full_name | phone | completeness_score | updated_at | |
|---|---|---|---|---|---|---|
| CRM | MK-1 | Ana Lopez | ana@example.com | NULL | 90 | 2025-02-01 09:00 |
| Billing | MK-1 | A. Lopez | ana.l@work.com | +1 555 0101 | 70 | 2025-03-01 09:00 |
| Support | MK-1 | Ana M Lopez | NULL | +1 555 0199 | 70 | 2025-03-10 09:00 |
| CRM | MK-2 | Raj Patel | raj@example.com | +91 98000 | 80 | 2025-01-15 09:00 |
Engineer check. Grain: one row per match_key. NULL: a field NULL in every source stays NULL; the FILTER only stops NULLs from beating real values.
#Summary
Most hard SQL problems are time problems in disguise. Overlapping spans merge with a running maximum and a running sum of new-period flags. Repeated readings compress into states that end where the next one begins. Concurrency is a running sum of +1 and −1, with markers for the moments nothing happens. Prices that depend on cumulative usage come from a running total and a clamp. Attribution and market data need range or as-of joins, with a deterministic tiebreak and a left join so unmatched rows stay visible. Averages over sparse data must divide by calendar time, not rows. And whenever data is versioned or comes from several systems, decide the order of operations explicitly: latest version before the business filter, and survivorship field by field.
#Concepts at a glance
| Concept | In one line | Why it matters |
|---|---|---|
| Interval merge | Running MAX(end) of earlier rows, then islands | Contained spans don't break periods |
| Latest version, then filter | ROW_NUMBER first, business rule second | Filtering first resurrects reversed versions |
| Expected grid | Every entity × every bucket, left-joined to data | Silence can't be found in the data itself |
| State intervals | Change flags, run numbers, end at the next start | Durations aren't cut short at the last reading |
| Sweep line | +1 / −1 events, running sum, hour markers | Concurrency in one sort, empty hours included |
| Tier clamp | LEAST(to, cap) − GREATEST(from, floor) | A day's usage can straddle tiers |
| Range join + one winner | Window join, QUALIFY ROW_NUMBER() = 1, tiebreak | Each conversion is credited once, the same way every run |
| As-of join | Latest row at or before a time | Equality joins on timestamps almost never match |
| Calendar-day averages | Divide by days, not rows | Sparse data inflates ROWS averages |
| Field-level survivorship | Rank each field's non-NULL values separately | The best record can still be missing a field |
#Test yourself
Why does interval merging use a running maximum of end dates instead of LAG(end_date)?
A long early span can contain a later short one. Comparing the next span with the short span's end would start a new period inside coverage that never stopped.
An hour has no trip starting or ending in it. Why can a sweep-line query lose it, and how do you keep it?
The running sum only has rows at start and end events, so the hour has no rows to group. Add a zero-delta marker at every hour start; it carries the running count into the hour.
A SKU sold 14 units on 2 of the last 7 days. What is its average daily demand, and what does AVG(units) OVER (ROWS 6 PRECEDING) report?
28 ÷ 7 = 4 per day. The ROWS average divides by the two rows it finds and reports 14.
Why does CASE WHEN transit_hours > sla THEN 'BREACHED' ELSE 'MET' END report an undelivered shipment as met?
Its transit time is NULL, the comparison is unknown, and unknown falls through to ELSE. Handle the NULL case in an earlier branch.
What does an as-of join return for a trade that happens before the first quote of its symbol?
With a left as-of join, the trade with NULL quote columns. With an inner as-of join, nothing: the trade disappears.
#Sources
- Snowflake: ASOF JOIN, TIME_SLICE, ARRAY_AGG, GREATEST
- DuckDB: AsOf joins, time_bucket, window functions and IGNORE NULLS
- PostgreSQL: date_bin