Skip to main content
SQL Interviews intermediate Lesson 8 of 10

Gaps and Islands

Consecutive-day streaks, session grouping, and merging overlapping intervals — the pattern that looks hardest and reduces to subtracting a row number.

“The longest streak of consecutive days”, “group these events into sessions”, “merge overlapping bookings” — one pattern. It looks like the hardest thing in SQL and reduces to a subtraction.

The trick

CREATE TEMP TABLE logins (user_id INTEGER, login_date DATE);
INSERT INTO logins VALUES
    (1, DATE '2024-03-01'), (1, DATE '2024-03-02'), (1, DATE '2024-03-03'),
    (1, DATE '2024-03-07'), (1, DATE '2024-03-08'),
    (2, DATE '2024-03-01'), (2, DATE '2024-03-05');

SELECT user_id, login_date,
       row_number() OVER (PARTITION BY user_id ORDER BY login_date)     AS rn,
       login_date - row_number() OVER (PARTITION BY user_id
                                       ORDER BY login_date) * INTERVAL 1 DAY AS grp
FROM logins ORDER BY user_id, login_date;
┌─────────┬────────────┬───────┬─────────────────────┐
│ user_id │ login_date │  rn   │         grp         │
├─────────┼────────────┼───────┼─────────────────────┤
│       1 │ 2024-03-01 │     1 │ 2024-02-29 00:00:00 │
│       1 │ 2024-03-02 │     2 │ 2024-02-29 00:00:00 │
│       1 │ 2024-03-03 │     3 │ 2024-02-29 00:00:00 │
│       1 │ 2024-03-07 │     4 │ 2024-03-03 00:00:00 │
│       1 │ 2024-03-08 │     5 │ 2024-03-03 00:00:00 │
│       2 │ 2024-03-01 │     1 │ 2024-02-29 00:00:00 │
│       2 │ 2024-03-05 │     2 │ 2024-03-03 00:00:00 │
└─────────┴────────────┴───────┴─────────────────────┘

Look at the grp column. The first three rows share 2024-02-29; rows four and five share 2024-03-03. The value is constant inside a streak and changes at every gap.

The reason is one sentence:

“Inside a consecutive run, the date increases by one per row and so does the row number. Their difference is therefore fixed. When a day is skipped the date jumps ahead but the row number does not, so the difference moves — which marks a new island.”

The value itself is meaningless — it is just a group key.

Longest streak

WITH marked AS (
    SELECT user_id, login_date,
           login_date - row_number() OVER (PARTITION BY user_id ORDER BY login_date)
                        * INTERVAL 1 DAY AS grp
    FROM logins
),
islands AS (
    SELECT user_id, grp,
           min(login_date) AS streak_start,
           max(login_date) AS streak_end,
           count(*)        AS streak_length
    FROM marked GROUP BY user_id, grp
)
SELECT user_id, streak_start, streak_end, streak_length
FROM islands ORDER BY user_id, streak_start;
┌─────────┬──────────────┬────────────┬───────────────┐
│ user_id │ streak_start │ streak_end │ streak_length │
├─────────┼──────────────┼────────────┼───────────────┤
│       1 │ 2024-03-01   │ 2024-03-03 │             3 │
│       1 │ 2024-03-07   │ 2024-03-08 │             2 │
│       2 │ 2024-03-01   │ 2024-03-01 │             1 │
│       2 │ 2024-03-05   │ 2024-03-05 │             1 │
└─────────┴──────────────┴────────────┴───────────────┘

The longest per user is then a top-N-per-group on streak_length — the pattern from lesson 5, reused:

WITH islands AS (/* as above */)
SELECT DISTINCT ON (user_id) user_id, streak_start, streak_end, streak_length
FROM islands ORDER BY user_id, streak_length DESC, streak_start;
┌─────────┬──────────────┬────────────┬───────────────┐
│ user_id │ streak_start │ streak_end │ streak_length │
├─────────┼──────────────┼────────────┼───────────────┤
│       1 │ 2024-03-01   │ 2024-03-03 │             3 │
│       2 │ 2024-03-01   │ 2024-03-01 │             1 │
└─────────┴──────────────┴────────────┴───────────────┘

Duplicates break it

INSERT INTO logins VALUES (3, DATE '2024-03-01'), (3, DATE '2024-03-01'), (3, DATE '2024-03-02');

WITH marked AS (
    SELECT user_id, login_date,
           login_date - row_number() OVER (PARTITION BY user_id ORDER BY login_date)
                        * INTERVAL 1 DAY AS grp
    FROM logins WHERE user_id = 3
)
SELECT login_date, grp, count(*) OVER (PARTITION BY grp) AS island_size FROM marked;
┌────────────┬─────────────────────┬─────────────┐
│ login_date │         grp         │ island_size │
├────────────┼─────────────────────┼─────────────┤
│ 2024-03-01 │ 2024-02-29 00:00:00 │           1 │
│ 2024-03-01 │ 2024-02-28 00:00:00 │           1 │
│ 2024-03-02 │ 2024-02-28 00:00:00 │           1 │
└────────────┴─────────────────────┴─────────────┘

User 3 logged in twice on 1 March and once on 2 March — a two-day streak. The query reports three separate islands, because the row number advanced on the duplicate while the date did not.

The fix is to deduplicate first, and dense_rank() is the alternative that handles it in one step:

WITH marked AS (
    SELECT DISTINCT user_id, login_date,
           login_date - dense_rank() OVER (PARTITION BY user_id ORDER BY login_date)
                        * INTERVAL 1 DAY AS grp
    FROM logins WHERE user_id = 3
)
SELECT min(login_date) AS start, max(login_date) AS finish, count(*) AS days
FROM marked GROUP BY grp;
┌────────────┬────────────┬───────┐
│   start    │   finish   │ days  │
├────────────┼────────────┼───────┤
│ 2024-03-01 │ 2024-03-02 │     2 │
└────────────┴────────────┴───────┘

dense_rank() gives duplicates the same number, so the difference stays constant across them. Knowing why the naive version fails is worth more than knowing the fix.

Sessionisation: the same idea with a threshold

CREATE TEMP TABLE hits (user_id INTEGER, ts TIMESTAMP);
INSERT INTO hits VALUES
    (1, TIMESTAMP '2024-03-01 10:00:00'),
    (1, TIMESTAMP '2024-03-01 10:05:00'),
    (1, TIMESTAMP '2024-03-01 10:20:00'),
    (1, TIMESTAMP '2024-03-01 14:00:00'),
    (1, TIMESTAMP '2024-03-01 14:02:00');

WITH gaps AS (
    SELECT user_id, ts,
           datediff('minute', lag(ts) OVER (PARTITION BY user_id ORDER BY ts), ts) AS gap_min
    FROM hits
),
flagged AS (
    SELECT *, CASE WHEN gap_min IS NULL OR gap_min > 30 THEN 1 ELSE 0 END AS is_new_session
    FROM gaps
),
sessioned AS (
    SELECT *, sum(is_new_session) OVER (PARTITION BY user_id ORDER BY ts
                                        ROWS UNBOUNDED PRECEDING) AS session_id
    FROM flagged
)
SELECT session_id, min(ts) AS started, max(ts) AS ended,
       count(*) AS hits,
       datediff('minute', min(ts), max(ts)) AS duration_min
FROM sessioned GROUP BY user_id, session_id ORDER BY session_id;
┌────────────┬─────────────────────┬─────────────────────┬───────┬──────────────┐
│ session_id │       started       │        ended        │ hits  │ duration_min │
├────────────┼─────────────────────┼─────────────────────┼───────┼──────────────┤
│          1 │ 2024-03-01 10:00:00 │ 2024-03-01 10:20:00 │     3 │           20 │
│          2 │ 2024-03-01 14:00:00 │ 2024-03-01 14:02:00 │     2 │            2 │
└────────────┴─────────────────────┴─────────────────────┴───────┴──────────────┘

Two sessions from five hits: three in the morning, two in the afternoon, split at the 3h40m gap. The 10:00→10:05→10:20 hits are 5 and 15 minutes apart, both under the timeout; the jump to 14:00 is 220 minutes and starts a new session.

The is_new_session flag is 1 on the very first row of each user because lag returns NULL there — gap_min IS NULL handles it, and forgetting that clause gives every user a session 0 containing only their first hit.

The mechanism in one sentence: a running SUM of a 0/1 flag increments only where the flag is 1, so it is a group id. The 30-minute timeout is the standard web analytics definition and is a parameter to ask about — some products use 30 minutes of inactivity, others a fixed daily boundary.

Merging overlapping intervals

CREATE TEMP TABLE bookings (room VARCHAR, start_at DATE, end_at DATE);
INSERT INTO bookings VALUES
    ('A', DATE '2024-03-01', DATE '2024-03-05'),
    ('A', DATE '2024-03-03', DATE '2024-03-08'),   -- overlaps
    ('A', DATE '2024-03-04', DATE '2024-03-06'),   -- fully contained
    ('A', DATE '2024-03-12', DATE '2024-03-15'),   -- separate
    ('B', DATE '2024-03-01', DATE '2024-03-02');

WITH ordered AS (
    SELECT room, start_at, end_at,
           max(end_at) OVER (PARTITION BY room ORDER BY start_at
                             ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS prev_max_end
    FROM bookings
),
flagged AS (
    SELECT *, CASE WHEN prev_max_end IS NULL OR start_at > prev_max_end THEN 1 ELSE 0 END AS is_new
    FROM ordered
),
grouped AS (
    SELECT *, sum(is_new) OVER (PARTITION BY room ORDER BY start_at
                                ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS grp
    FROM flagged
)
SELECT room, grp, min(start_at) AS merged_start, max(end_at) AS merged_end
FROM grouped GROUP BY room, grp ORDER BY room, merged_start;
┌─────────┬───────┬──────────────┬────────────┐
│  room   │  grp  │ merged_start │ merged_end │
├─────────┼───────┼──────────────┼────────────┤
│ A       │     1 │ 2024-03-01   │ 2024-03-08 │
│ A       │     2 │ 2024-03-12   │ 2024-03-15 │
│ B       │     1 │ 2024-03-01   │ 2024-03-02 │
└─────────┴───────┴──────────────┴────────────┘

Room A’s three overlapping bookings merge into 2024-03-01 to 2024-03-08; the March 12 one stays separate. Room B has a single booking and produces a single row.

The critical piece is max(end_at) OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) — the running maximum of all previous ends, not just the previous row’s end. Booking three (March 4–6) is entirely inside booking two (March 3–8); comparing only to the immediately previous row would see end_at going backwards and wrongly start a new group.

“The running max is what handles a fully-contained interval. lag(end_at) looks equivalent and breaks on exactly that case — which is why it is usually the test input.”

Recognising it

QUESTION                                        SHAPE
"consecutive days / longest streak"             date - row_number(), group by the difference
"N days in a row"                               same, then filter on count(*) >= N
"group events into sessions"                    gap flag + running SUM
"how many distinct visits"                      sessionise, then count the groups
"merge overlapping ranges"                      running max of end + flag + running SUM
"find the gaps in a sequence"                   the islands query, then lag on the boundaries
duplicates in the source                        dense_rank(), or deduplicate first

Finding the gaps themselves

WITH islands AS (
    SELECT user_id, min(login_date) AS s, max(login_date) AS e
    FROM (SELECT user_id, login_date,
                 login_date - row_number() OVER (PARTITION BY user_id ORDER BY login_date)
                              * INTERVAL 1 DAY AS grp
          FROM logins WHERE user_id = 1) m
    GROUP BY user_id, grp
)
SELECT e + INTERVAL 1 DAY AS gap_start,
       lead(s) OVER (ORDER BY s) - INTERVAL 1 DAY AS gap_end,
       datediff('day', e, lead(s) OVER (ORDER BY s)) - 1 AS gap_days
FROM islands QUALIFY gap_end IS NOT NULL;
┌─────────────────────┬─────────────────────┬──────────┐
│      gap_start      │       gap_end       │ gap_days │
├─────────────────────┼─────────────────────┼──────────┤
│ 2024-03-04 00:00:00 │ 2024-03-06 00:00:00 │        3 │
└─────────────────────┴─────────────────────┴──────────┘

Islands first, then lead across them gives the gaps between. The three missing days are the 4th, 5th and 6th.

QUALIFY filters on a window function without a wrapping CTE — DuckDB, Snowflake, BigQuery and Databricks support it; PostgreSQL and MySQL do not. Another dialect feature to name as one.

Practice

1. Compute date - row_number() over a streak with a gap.
2024-02-29, 2024-02-29, 2024-02-29, 2024-03-03, 2024-03-03

Constant inside a run, changed at the gap. Both sides increase by one per row inside a streak, so the difference is fixed.

2. Run the streak query on data with two logins on the same day.
3 islands where there should be 2

The row number advanced on the duplicate and the date did not. Deduplicate first, or use dense_rank(), which gives ties the same number.

3. Sessionise events with a 30-minute timeout.
session 1: 3 hits, 20 min      session 2: 2 hits, 2 min

A running SUM of a 0/1 boundary flag increments only at boundaries, so it is a group id. The timeout is a parameter worth asking about.

4. Merge intervals using lag(end_at) instead of a running max.
A fully contained interval wrongly starts a new group.

March 4–6 sits inside March 3–8. Only the running maximum of all previous ends handles it, and it is usually the planted test case.

Next: query plans and performance — reading EXPLAIN, and the four reasons a query is slow.

Frequently Asked Questions

What is the gaps-and-islands trick?
For consecutive integers or dates, `value - row_number()` is constant within a run and changes at every gap. Grouping by that difference groups each run together. It works because both sides increase by one per row inside a streak, so the difference stays fixed.
How do I group events into sessions?
Flag each row where the gap from the previous event exceeds the timeout, then take a running SUM of that flag — it increments only at session boundaries, so it is a session id. This is the standard sessionisation query and is used verbatim in production analytics.
Does the row-number trick work on dates?
Yes, if the dates are unique per group. `order_date - row_number() * INTERVAL 1 DAY` is constant within a consecutive run. If a user can have several rows on the same day, deduplicate to one row per day first, otherwise the row numbers outpace the dates.
How do I merge overlapping intervals in SQL?
Order by start, carry a running maximum of the previous end, flag any row whose start is later than that maximum as a new group, then take a running SUM of the flags. Group by the result and take MIN(start) and MAX(end). The running max is what handles an interval fully contained in an earlier one.