Total Hours Between Consecutive Events
A hard SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- hard
- Seniority
- L4
- Asked in
- Shopify
Problem
event_data holds one row per event with its event_type, event_timestamp and user_id, and a row with no user_id comes from an automated job, so it never sits on any timeline. Each event type is a single timeline that interleaves every user's events, and a quiet stretch is a gap of more than 30 days between a user event of that type and the very next one of that type, whoever triggered it. Report the total hours each type spent in quiet stretches.
Hours between state changes.
Tables
event_data
Columns: event_id INTEGER, event_type TEXT, user_id INTEGER, event_timestamp TEXT, payload TEXT, tags TEXT, properties TEXT
| event_id | event_type | user_id | event_timestamp | payload | tags | properties |
|---|---|---|---|---|---|---|
| 3059 | button_click | 100 | 2026-02-02 01:07:00 | {"button_id": "cancel", "element_class": "btn-primary", "position": {"x": 13, "y": 7}} | ["desktop", "web", "returning"] | {"os": "Edge", "language": "US", "screen_size": "en", "utm_source": "mobile"} |
| 3118 | purchase | 197 | 2026-03-03 02:14:00 | {"amount": 56.93, "currency": "GBP", "items": [{"sku": "SKU1002", "qty": 3}]} | ["ios", "premium", "new_user", "organic"] | {"country": "Windows", "device_type": "FR", "referrer": "fr", "utm_medium": "1920x1080", "browser": "twitter"} |
| 3177 | signup | 294 | 2026-04-04 03:21:00 | {"signup_method": "apple", "referral_code": "REF3X", "accepted_terms": true} | ["android", "free", "campaign_a", "referral", "experiment_v1"] | {"language": "iOS", "screen_size": "es", "utm_source": "desktop", "session_id": "bing", "os": "organic"} |
| 3236 | login | 391 | 2026-05-05 04:28:00 | {"login_method": "password", "remember_me": true, "mfa_used": false} | ["web", "returning"] | {"device_type": "JP", "referrer": "ja", "utm_medium": "1440x900"} |
| 3295 | logout | 488 | 2026-06-06 05:35:00 | {"action": "logout", "value": 50} | ["premium", "new_user", "organic"] | {"screen_size": "de", "utm_source": "tablet", "session_id": "amazon", "os": "Chrome"} |
| 3354 | search | 585 | 2026-07-07 06:42:00 | {"query": "phone", "results_count": 102, "filters": {"category": "electronics"}} | ["free", "campaign_a", "referral", "experiment_v1"] | {"referrer": "mobile", "utm_medium": "375x812", "browser": "email", "country": "Edge", "device_type": "US"} |
| 3413 | add_to_cart | NULL | 2026-08-08 07:49:00 | {"product_id": 1007, "quantity": 2, "price": 58.49, "variant": "medium"} | ["returning", "campaign_b", "featured", "experiment_v2", "ios"] | {"utm_source": "fr", "session_id": "twitter", "os": "Safari", "language": "Windows", "screen_size": "FR"} |
| 3472 | open | 779 | 2026-09-09 08:56:00 | {"action": "open", "value": 80} | ["new_user", "organic"] | {"utm_medium": "bing", "browser": "organic", "country": "iOS"} |
| 3531 | crash | 876 | 2026-01-10 09:03:00 | {"action": "crash", "value": 90} | ["campaign_a", "referral", "experiment_v1"] | {"session_id": "direct", "os": "Firefox", "language": "macOS", "screen_size": "JP"} |
| 3590 | error | 973 | 2026-02-11 10:10:00 | {"action": "error", "value": 100} | ["campaign_b", "featured", "experiment_v2", "ios"] | {"browser": "Chrome", "country": "Android", "device_type": "DE", "referrer": "de", "utm_medium": "tablet"} |
Expected output
| event_type | total_hours |
|---|---|
| button_click | 38704.06666665897 |
| checkout_start | 33182.63333333284 |
| crash | 37362.73333332315 |
| error | 35008.46666665748 |
| login | 31882.366666674614 |
Worked solution and explanation
This is a gap-length filter dressed up as an activity report. Here is the part nobody says out loud: summing every gap between consecutive events is a trick with no teeth, because the gaps telescope and the total is just the last timestamp minus the first. The 30-day bar is what gives the problem teeth: once only long gaps count, every individual gap has to exist as its own row, and that takes a partitioned LAG. The second trap is quieter. Automated rows (no user_id) must leave before the gaps are measured, because a job firing at the edge of a quiet month moves where that stretch starts or ends, and a filter applied afterwards is too late to put it back.
Why the shortcut stops working
- Every gap counted
- The sum collapses: (t2 - t1) + (t3 - t2) + ... equals
MAX(event_timestamp) - MIN(event_timestamp)per type. A plainGROUP BY event_typeanswers it, and the window was never needed. - Only gaps over 30 days
- Whether a gap counts depends on its own length, so the sum no longer telescopes. You need each adjacent pair as a row, test it against 720 hours, then total the survivors.
Building it
Step 1: Drop automated rows inside the subquery
WHERE user_id IS NOT NULL sits in the same SELECT as the window, so it runs before LAG is evaluated. Automated rows never enter any type's timeline, so they can neither split a long stretch nor serve as its first or last endpoint.
Step 2: Pair each event with its same-type predecessor
LAG(event_timestamp) OVER (PARTITION BY event_type ORDER BY event_timestamp) hands every row the previous timestamp in its own type's timeline. No user_id goes in the partition: every user's events share the type's timeline. The first event of each type gets NULL.
Step 3: Turn the difference into hours
JULIANDAY differences are in days, so multiply by 24 to get gap_hours. Thirty days is then 720, and comparing against 30 is the silent unit bug.
Step 4: Keep the long gaps and total them
The outer WHERE gap_hours > 720 drops short gaps and, for free, the NULL first gap, since a comparison with NULL is never true. SUM(gap_hours) grouped by event_type gives total_hours.
SELECT event_type, SUM(gap_hours) AS total_hours
FROM (
SELECT
event_type,
(JULIANDAY(event_timestamp) - JULIANDAY(
LAG(event_timestamp) OVER (
PARTITION BY event_type
ORDER BY event_timestamp
)
)) * 24 AS gap_hours
FROM event_data
WHERE user_id IS NOT NULL
) gaps
WHERE gap_hours > 720
GROUP BY event_typeThe inner WHERE shapes the timelines; the outer WHERE judges the gaps.
Common follow-up questions
- How would you return the single longest quiet stretch per type, with the dates it started and ended? (Carrying `LAG(event_timestamp)` as its own column and taking the row with the largest gap per type.)
- What if two events of the same type share an `event_timestamp`? (Adding `event_id` as a tiebreaker in the window's `ORDER BY` so `LAG` is deterministic.)
- What changes if quiet stretches should be measured per user rather than per event type? (Partitioning by `user_id` and noticing the zipf skew moves to the heaviest users.)
- How would you restrict this to stretches that ended in 2026 without cutting a stretch that started the year before? (Filtering on the later timestamp after the window, not on the rows before it.)