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_data: 10 of 200 rows
event_idevent_typeuser_idevent_timestamppayloadtagsproperties
3059button_click1002026-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"}
3118purchase1972026-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"}
3177signup2942026-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"}
3236login3912026-05-05 04:28:00{"login_method": "password", "remember_me": true, "mfa_used": false}["web", "returning"]{"device_type": "JP", "referrer": "ja", "utm_medium": "1440x900"}
3295logout4882026-06-06 05:35:00{"action": "logout", "value": 50}["premium", "new_user", "organic"]{"screen_size": "de", "utm_source": "tablet", "session_id": "amazon", "os": "Chrome"}
3354search5852026-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"}
3413add_to_cartNULL2026-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"}
3472open7792026-09-09 08:56:00{"action": "open", "value": 80}["new_user", "organic"]{"utm_medium": "bing", "browser": "organic", "country": "iOS"}
3531crash8762026-01-10 09:03:00{"action": "crash", "value": 90}["campaign_a", "referral", "experiment_v1"]{"session_id": "direct", "os": "Firefox", "language": "macOS", "screen_size": "JP"}
3590error9732026-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

First 5 of 12 rows
event_typetotal_hours
button_click38704.06666665897
checkout_start33182.63333333284
crash37362.73333332315
error35008.46666665748
login31882.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 plain GROUP BY event_type answers 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.

Filter, window, filter, total
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_type

The inner WHERE shapes the timelines; the outer WHERE judges the gaps.

Common follow-up questions