SQL Gaps and Islands: Finding Consecutive Runs, Streaks, and Sessions

Solve SQL gaps and islands with deduplicated date streaks, active-month runs, threshold sessions, state changes, and explicit missing ranges.

Author: PracHub

Published: 8/14/2026

SQL Gaps and Islands: Finding Consecutive Runs, Streaks, and Sessions

August 14, 2026
25 min read
SQL Gaps and Islands: Finding Consecutive Runs, Streaks, and Sessions

Quick Overview

A Data Scientist guide to gaps and islands in PostgreSQL. Six executed examples cover duplicate-safe daily streaks, longest-run ranking, filter-before-numbering, 30-minute sessions, repeated-state islands, and missing-date gaps.

Data ScientistFree

Gaps and islands problems ask where ordered records form consecutive runs. The difficult part is not the final GROUP BY; it is defining consecutive for the business question and assigning a stable island key before aggregation.

Start by naming the entity, order column, duplicate policy, and allowed gap. A daily streak, an active-month run, a 30-minute session, and repeated device states need different markers even though all four end with grouped boundaries.

Deduplicate before finding consecutive dates

The source can contain several events for one user on one date. A daily streak counts distinct dates, so the first CTE removes duplicate user-date rows. Subtracting the row number from each date creates a constant key within a consecutive run.

Input: user_activity

event_iduser_idactivity_date
112026-01-01
212026-01-02
312026-01-02
412026-01-04
512026-01-05
612026-01-06
722026-01-01
822026-01-03
922026-01-04
1022026-01-06
WITH distinct_days AS (
  SELECT DISTINCT user_id, activity_date
  FROM user_activity
),
tagged AS (
  SELECT
    user_id,
    activity_date,
    activity_date
      - ROW_NUMBER() OVER (
          PARTITION BY user_id
          ORDER BY activity_date
        )::integer AS island_key
  FROM distinct_days
)
SELECT
  user_id,
  MIN(activity_date) AS island_start,
  MAX(activity_date) AS island_end,
  COUNT(*) AS active_days
FROM tagged
GROUP BY user_id, island_key
ORDER BY user_id, island_start;
Row flow from duplicated activity to date islands Ten event rows deduplicate to nine user-date rows, row-number subtraction assigns five island keys, and grouping returns five streak rows. 10 event rows1 duplicate user-day9 distinct datesassign constant run keys5 island rowsstart, end, active days
Deduplication protects both the sequence number and the final day count.

Output

user_idisland_startisland_endactive_days
12026-01-012026-01-022
12026-01-042026-01-063
22026-01-012026-01-011
22026-01-032026-01-042
22026-01-062026-01-061

The subtraction works because both the date and row number advance by one inside a run. A missing date changes their difference and starts a new key. The SQL GROUP BY guide explains the final grain change.

Rank completed islands, not detail rows

To select the longest streak per user, first build complete island summaries. Rank those summaries by length, then use the start date as a deterministic tie breaker.

Input: user_activity

event_iduser_idactivity_date
112026-01-01
212026-01-02
312026-01-02
412026-01-04
512026-01-05
612026-01-06
722026-01-01
822026-01-03
922026-01-04
1022026-01-06
WITH distinct_days AS (
  SELECT DISTINCT user_id, activity_date
  FROM user_activity
),
tagged AS (
  SELECT
    user_id,
    activity_date,
    activity_date
      - ROW_NUMBER() OVER (
          PARTITION BY user_id
          ORDER BY activity_date
        )::integer AS island_key
  FROM distinct_days
),
islands AS (
  SELECT
    user_id,
    MIN(activity_date) AS island_start,
    MAX(activity_date) AS island_end,
    COUNT(*) AS active_days
  FROM tagged
  GROUP BY user_id, island_key
),
ranked AS (
  SELECT
    islands.*,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY active_days DESC, island_start
    ) AS streak_rank
  FROM islands
)
SELECT
  user_id,
  island_start,
  island_end,
  active_days
FROM ranked
WHERE streak_rank = 1
ORDER BY user_id;
Row flow from date islands to the longest streak per user Nine distinct user-date rows become five completed islands, which are ranked within two users and filtered to two winning streak rows. 9 distinct datesbuild all runs5 island rowsrank within each user2 winning rowsone per user
Ranking earlier would rank activity dates, not finished streaks.

Output

user_idisland_startisland_endactive_days
12026-01-042026-01-063
22026-01-032026-01-042

Use RANK or DENSE_RANK when every tied longest streak should survive. Use ROW_NUMBER with a stated tie breaker when the contract requires exactly one. The window-functions guide develops those choices.

Filter the population before assigning sequence numbers

Inactive subscription months are separators, not members of an active island. Filter them before ROW_NUMBER; otherwise they consume sequence positions and distort the active-run key.

Input: subscription_months

user_idmonth_startis_active
12026-01-01true
12026-02-01true
12026-03-01false
12026-04-01true
12026-05-01true
12026-06-01true
22026-01-01true
22026-02-01false
22026-03-01true
22026-04-01true
WITH active_months AS (
  SELECT user_id, month_start
  FROM subscription_months
  WHERE is_active
),
tagged AS (
  SELECT
    user_id,
    month_start,
    (
      EXTRACT(YEAR FROM month_start)::integer * 12
      + EXTRACT(MONTH FROM month_start)::integer
      - ROW_NUMBER() OVER (
          PARTITION BY user_id
          ORDER BY month_start
        )::integer
    ) AS island_key
  FROM active_months
)
SELECT
  user_id,
  MIN(month_start) AS island_start,
  MAX(month_start) AS island_end,
  COUNT(*) AS active_months
FROM tagged
GROUP BY user_id, island_key
ORDER BY user_id, island_start;
Row flow for active-month islands Ten monthly rows filter to eight active rows before numbering, and the dense month index groups them into four active subscription islands. 10 monthly rowsremove 2 inactive rows8 active rowsnumber after filtering4 active islandstwo per user
A year-times-twelve month index gives consecutive calendar months a difference of one across year boundaries.

Output

user_idisland_startisland_endactive_months
12026-01-012026-02-012
12026-04-012026-06-013
22026-01-012026-01-011
22026-03-012026-04-012

Sessionize with a threshold and an explicit equality rule

A session starts when there is no prior event or the gap is greater than 30 minutes. With that rule, a gap exactly equal to 30 minutes remains in the current session. The running sum of start flags becomes a session number.

Input: click_events

event_iduser_idevent_ts
112026-03-01 09:00:00
212026-03-01 09:20:00
312026-03-01 09:50:00
412026-03-01 10:21:00
522026-03-01 09:00:00
622026-03-01 09:31:00
722026-03-01 09:45:00
WITH with_previous AS (
  SELECT
    event_id,
    user_id,
    event_ts,
    LAG(event_ts) OVER (
      PARTITION BY user_id
      ORDER BY event_ts, event_id
    ) AS previous_ts
  FROM click_events
),
marked AS (
  SELECT
    *,
    CASE
      WHEN previous_ts IS NULL
        OR event_ts - previous_ts > INTERVAL '30 minutes'
      THEN 1 ELSE 0
    END AS session_start
  FROM with_previous
),
numbered AS (
  SELECT
    *,
    SUM(session_start) OVER (
      PARTITION BY user_id
      ORDER BY event_ts, event_id
      ROWS UNBOUNDED PRECEDING
    ) AS session_number
  FROM marked
)
SELECT
  user_id,
  session_number,
  MIN(event_ts) AS session_start,
  MAX(event_ts) AS session_end,
  COUNT(*) AS event_count
FROM numbered
GROUP BY user_id, session_number
ORDER BY user_id, session_number;
Row flow from click events to threshold sessions Seven ordered click rows receive four session-start flags, a running sum assigns session numbers, and grouping produces four session rows. 7 click rowsordered per user4 start flagsgap greater than 30 min4 session rows2 per user
User 1's 09:20 to 09:50 gap is exactly 30 minutes, so it stays in session 1.

Output

user_idsession_numbersession_startsession_endevent_count
112026-03-01 09:00:002026-03-01 09:50:003
122026-03-01 10:21:002026-03-01 10:21:001
212026-03-01 09:00:002026-03-01 09:00:001
222026-03-01 09:31:002026-03-01 09:45:002

Tie the ordering to a stable key when timestamps can repeat. Without event_id, equal timestamps can be processed in an unspecified order even if the session totals happen to remain unchanged.

Collapse repeated states and expose missing ranges

For runs of equal values, mark a boundary whenever the current state differs from the previous state. PostgreSQL's IS DISTINCT FROM treats NULL as a comparable state and makes the first-row behavior explicit.

Input: device_states

observation_iddevice_idobserved_atstate
1d12026-03-01 08:00:00offline
2d12026-03-01 08:05:00offline
3d12026-03-01 08:10:00online
4d12026-03-01 08:20:00online
5d12026-03-01 08:30:00degraded
6d12026-03-01 08:40:00online
WITH with_previous AS (
  SELECT
    *,
    ROW_NUMBER() OVER (
      PARTITION BY device_id
      ORDER BY observed_at, observation_id
    ) AS sequence_number,
    LAG(state) OVER (
      PARTITION BY device_id
      ORDER BY observed_at, observation_id
    ) AS previous_state
  FROM device_states
),
numbered AS (
  SELECT
    *,
    SUM(
      CASE
        WHEN sequence_number = 1
          OR state IS DISTINCT FROM previous_state
        THEN 1 ELSE 0
      END
    ) OVER (
      PARTITION BY device_id
      ORDER BY observed_at, observation_id
      ROWS UNBOUNDED PRECEDING
    ) AS island_number
  FROM with_previous
)
SELECT
  device_id,
  island_number,
  state,
  MIN(observed_at) AS state_start,
  MAX(observed_at) AS state_end,
  COUNT(*) AS observations
FROM numbered
GROUP BY device_id, island_number, state
ORDER BY device_id, island_number;
Row flow from repeated states to state islands Six ordered observations produce four state-change flags, the running sum creates four island numbers, and grouping returns four state-run rows. 6 state rowsordered observations4 change flagsrunning island number4 state islandsonline appears twice
The two online runs stay separate because a degraded observation sits between them.

Output

device_idisland_numberstatestate_startstate_endobservations
d11offline2026-03-01 08:00:002026-03-01 08:05:002
d12online2026-03-01 08:10:002026-03-01 08:20:002
d13degraded2026-03-01 08:30:002026-03-01 08:30:001
d14online2026-03-01 08:40:002026-03-01 08:40:001

Sometimes the gaps, rather than the islands, are the output. LEAD pairs each distinct activity date with the next one; the filter keeps pairs separated by at least one missing calendar date.

Input: user_activity

event_iduser_idactivity_date
112026-01-01
212026-01-02
312026-01-02
412026-01-04
512026-01-05
612026-01-06
722026-01-01
822026-01-03
922026-01-04
1022026-01-06
WITH distinct_days AS (
  SELECT DISTINCT user_id, activity_date
  FROM user_activity
),
with_next AS (
  SELECT
    user_id,
    activity_date,
    LEAD(activity_date) OVER (
      PARTITION BY user_id
      ORDER BY activity_date
    ) AS next_activity_date
  FROM distinct_days
)
SELECT
  user_id,
  activity_date AS gap_after,
  next_activity_date AS gap_before,
  next_activity_date - activity_date - 1 AS missing_days
FROM with_next
WHERE next_activity_date - activity_date > 1
ORDER BY user_id, activity_date;
Row flow from distinct dates to missing-date gaps Ten event rows deduplicate to nine user-date rows, lead pairs each row with its successor, and three pairs reveal a missing calendar date. 10 event rows9 distinct user-daysPair with next datekeep differences over 13 gap rows1 missing day each
The output describes open gaps between observed boundary dates, not generated rows for every missing date.

Output

user_idgap_aftergap_beforemissing_days
12026-01-022026-01-041
22026-01-012026-01-031
22026-01-042026-01-061

Generating every missing date requires a date spine rather than LEAD. See SQL date functions for dense calendar construction and SQL practice questions for larger sequences.

FAQ

What is an island in SQL?

An island is a maximal run of rows that satisfy a stated adjacency rule. Maximal means the run cannot be extended by the next or previous ordered row without breaking that rule.

Why must I deduplicate daily events first?

Two events on one date are not two consecutive dates. Duplicates change row numbers and inflate counts unless the business question is explicitly event-based.

When should I subtract ROW_NUMBER?

Use it when the ordered value advances by a fixed unit, such as one day or one dense month index. For time gaps with a tolerance or runs of equal states, boundary flags and a running sum are clearer.

Does a 30-minute gap start a new session?

Only if the contract says so. gap > 30 minutes keeps an exact 30-minute gap in the current session; gap >= 30 minutes starts a new one. State the equality rule.

How should ties in the longest streak be handled?

Use a ranking function that matches the output contract. Keep all ties with RANK or DENSE_RANK, or choose one deterministically with ROW_NUMBER and a documented secondary order.


Comments (0)