Gaps and Islands in SQL

Consecutive Days, Streaks and Gaps

Islands are the unbroken runs in an ordered sequence, and gaps are the missing stretches between them; gaps and islands is the SQL pattern for finding both. To find consecutive days, number each user's distinct days with ROW_NUMBER, subtract that number from the date, and group by the result: every day of one streak lands on the same value, and each group is 1 island.

Last updated: Proudly published by: Jeff Wahl9 min read

What are gaps and islands in SQL?

Islands are the unbroken runs in an ordered sequence, and gaps are the missing stretches between them; gaps and islands is the SQL pattern for finding both. Consecutive days is the classic case: a user is active on days 1, 2, 3, 5, 6, 9, so the islands are days 1 to 3, days 5 to 6 and day 9 alone, and the gaps are the missing days between them.

The same shape shows up in data engineering work all the time. Login streaks, uninterrupted subscription periods, runs of the same device status, sensor outages, sessions cut by an inactivity timeout and the longest winning streak are all islands in some ordered column. Interviewers like it because a plain GROUP BY cannot express it: the group a row belongs to depends on its neighbours, so you have to build a group key from window functions first.

The row number difference works when the sequence is made of consecutive integers or days. A LAG flag with a running sum works for any rule about what counts as a break.

Prepare for the interview
01 / Open invite
02min.

Know Gaps and Islands the way the interviewer who asks it knows it.

a Gaps and Islands query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1SELECT user_id,
2 COUNT(*) AS sessions
3FROM events
4WHERE ts >= NOW() - INTERVAL '7 day'
5
Execute your solution0.4s avg.
DatabricksInterview question
Solve a Gaps and Islands problem

How to find consecutive days in SQL with ROW_NUMBER

Start from 1 row per user and day, numbered in date order. The user's active days are 1, 2, 3, 5, 6, 9, so the row numbers are 1, 2, 3, 4, 5, 6. Subtract the row number from the day and you get 0, 0, 0, 1, 1, 3.

Inside an island, every row moves the day forward by 1 and the row number forward by 1, so their difference stays put. Day 1 gives 0, and so do 2 and 3. Across a gap, the day jumps while the row number rises by only 1, so the difference grows. Days 5 and 6 give 1, and day 9 gives 3, because it skipped 2 days.

A 9 day axis with 6 active days: days 1, 2 and 3 form an island with key 0, days 5 and 6 form an island with key 1, day 9 is an island with key 3, and 2 dashed gaps separate themA 9 day axis with 6 active days: days 1, 2 and 3 form an island with key 0, days 5 and 6 form an island with key 1, day 9 is an island with key 3, and 2 dashed gaps separate them

That difference is the island key. It means nothing by itself, and it only has to be equal within an island and different between islands. Grouping by user and key and taking MIN and MAX of the day gives each streak's first and last day, and COUNT gives its length. Here that is 3 streaks of 3 and 2 days, plus one of 1 days, and the longest is 3.

Consecutive days query for PostgreSQL

WITH days AS (
  SELECT DISTINCT user_id, login_date
  FROM logins
),
keyed AS (
  SELECT
    user_id,
    login_date,
    login_date - (ROW_NUMBER() OVER (
      PARTITION BY user_id ORDER BY login_date
    ))::int AS island_key
  FROM days
)
SELECT
  user_id,
  MIN(login_date) AS streak_start,
  MAX(login_date) AS streak_end,
  COUNT(*)        AS streak_days
FROM keyed
GROUP BY user_id, island_key
ORDER BY user_id, streak_start;

Streaks per user from a logins table with 1 or more rows per user and day

Duplicates, timestamps and the date arithmetic

Deduplicate first. ROW_NUMBER gives every row its own number, even when 2 rows share a date, so a user with 2 logins on day 2 would get row numbers that outrun the dates and split 1 streak into 2. The SELECT DISTINCT in the first CTE prevents that. If you would rather keep the duplicate rows, DENSE_RANK numbers each distinct date once, because it counts peer groups and gives equal values the same rank.

If the column is a timestamp, cast it to a date before numbering; otherwise each event becomes its own row and its own island. Pick the time zone on purpose: a login at 23:50 in 1 zone is a login on the next day in another, and a streak that crosses midnight depends on which one you chose.

The subtraction itself is engine specific. In PostgreSQL, subtracting an integer from a date returns a date and subtracting 2 dates returns a number of days, which is why the query casts ROW_NUMBER, a bigint, to int first. Other engines use a date function for the same step, such as shifting the date back by the row number in days. The same trick works on plain numbers: for consecutive ids or years, id - ROW_NUMBER() OVER (ORDER BY id) needs no date function.

How to find the gaps between days

The gaps are the other half of the question, and they need no grouping column at all. LEAD reads the next date within the same user, so each row can compare itself with its successor. When the next date is more than 1 day later, the days in between are missing. The gap starts the day after the current row and ends the day before the next one.

On the example days, that returns 2 gaps. The first covers days 4 to 4 and is 1 missing day; the second covers days 7 to 8 and is 2 missing days. The last row of each user has no successor, and LEAD returns NULL by default there, so the WHERE clause drops it. Once a user's streak is over, nothing after it is reported as a gap, which matches what most questions mean.

Gaps between consecutive days with LEAD

WITH days AS (
  SELECT DISTINCT user_id, login_date
  FROM logins
),
nxt AS (
  SELECT
    user_id,
    login_date,
    LEAD(login_date) OVER (
      PARTITION BY user_id ORDER BY login_date
    ) AS next_date
  FROM days
)
SELECT
  user_id,
  login_date + 1                AS gap_start,
  next_date - 1                 AS gap_end,
  next_date - login_date - 1    AS missing_days
FROM nxt
WHERE next_date - login_date > 1
ORDER BY user_id, gap_start;

Missing day ranges per user, using integer arithmetic on dates

When the break is a time limit: flag with LAG, then sum

The row number trick assumes a break means exactly 1 missing step. Real questions change the rule: a session ends after 30 minutes of silence, a streak survives 1 missed day, an order series resets when the price changes. For those, mark each row that starts a new island and number the islands with a running sum.

The LAG of the timestamp gives the previous event. A CASE expression returns 1 when the gap to it exceeds the limit and 0 otherwise, and the first row of each user, where LAG is NULL, falls into the 0 branch. A SUM of the flags ordered by time then counts how many breaks came before each row, which is the island number. Events in the same island share a number, and grouping by it gives each session's start and end, while counting its rows gives the size.

Rows with identical timestamps are peers, and an ordered aggregate with the default frame gives all of them the same running total, so ties stay in 1 session. Change the threshold and the same shape answers a different question, for instance > 2 on whole days allows 1 missed day inside a streak.

Sessions split by a 30 minute inactivity limit

WITH flagged AS (
  SELECT
    user_id,
    event_ts,
    CASE
      WHEN event_ts - LAG(event_ts) OVER (
             PARTITION BY user_id ORDER BY event_ts
           ) > INTERVAL '30 minutes'
      THEN 1 ELSE 0
    END AS starts_new_session
  FROM events
),
numbered AS (
  SELECT
    user_id,
    event_ts,
    SUM(starts_new_session) OVER (
      PARTITION BY user_id ORDER BY event_ts
    ) AS session_no
  FROM flagged
)
SELECT
  user_id,
  session_no,
  MIN(event_ts) AS session_start,
  MAX(event_ts) AS session_end,
  COUNT(*)      AS events
FROM numbered
GROUP BY user_id, session_no
ORDER BY user_id, session_start;

Break flag with LAG, island number with a running SUM

Runs of the same status: 2 row numbers instead of 1

Sometimes the sequence has no missing step at all, and a run is a stretch of rows with the same value, such as a device that is online, online, offline, online. The row number difference still works, with a second numbering. Number each user's rows in time order, number them again inside each user and status, and subtract the 2 numbers.

Within a run, both counters advance together, so their difference holds. When the status changes, the overall counter keeps climbing while the per-status counter does not, and the difference moves. Group by the difference within each user and status to get each run and its length.

Streak questions usually add 1 more step on top. The current streak is the island whose end is today or yesterday, the longest streak is the MAX of the island length, and a streak of at least N days is a HAVING COUNT(*) >= N on the grouped result.

Gaps and islands techniques compared

Row number key
  • Needs date arithmeticYes, or integers
  • Break ruleExactly 1 step
  • Window functions1 ROW_NUMBER
  • DuplicatesRemove first
LAG flag and SUM
  • Needs date arithmeticOnly for the limit
  • Break ruleAny threshold
  • Window functionsLAG and SUM
  • DuplicatesTies share a number
Recursive CTE
  • Needs date arithmeticYes
  • Break ruleAny condition
  • Window functionsNone
  • DuplicatesRemove first

How gaps and islands shows up in data engineering interviews

The question is rarely named. It arrives as consecutive logins or the longest streak of profitable months, and sometimes as uninterrupted employment spells or the periods a subscription was active. The tell is a request for a run of rows with a start and an end, or a count of consecutive anything. Say out loud that you recognise it, then state what must be equal inside a run and different across runs.

Check the data before you write the query. Ask whether a user can have several rows per day, whether the column is a date or a timestamp and in which time zone, whether ties are possible and what counts as a break. Those 4 answers decide whether you need the DISTINCT or the truncation, which DENSE_RANK fits, and where the threshold sits; asking them first is what interviewers read as production experience.

The catalogue holds a hard SQL problem on exactly this pattern, Consecutive Cost Growth Periods, which asks for 5 straight months of spending increases. The SQL practice problems page has it with the editor, and the SQL interview questions page covers the rest of the round. For the windows underneath this pattern, read the SQL window functions interview questions, and for how the round is run see the SQL round guide.

Mistakes that break a gaps and islands query

Forgetting PARTITION BY numbers all users as 1 long sequence, and a streak can leak from the end of 1 user into the start of the next. Ordering by something other than the date gives a grouping value that is stable by accident on small data and wrong on real data. Both fail silently, so test the query on 2 users whose dates interleave.

Putting the island expression in a WHERE clause fails because a window function cannot appear there. Compute it in a CTE or subquery, then group in the next step. Using COUNT(*) as the streak length is fine after deduplication and wrong before it, and measuring a streak as end minus start misses the days inside it if a date is absent, so count the rows.

For more practice with the building blocks, the SQL window functions practice page drills ROW_NUMBER and RANK one at a time, and also LAG. Advanced SQL covers the rest of the pattern family.

Gaps and islands SQL FAQ

What is the gaps and islands problem in SQL?+
It is the task of finding unbroken runs of an ordered sequence (islands) and the missing stretches between them (gaps). Typical cases are consecutive login days, uninterrupted employment periods, runs of the same status and sessions split by an inactivity limit.
How do you find consecutive days in SQL?+
Take each user's distinct dates, number them with ROW_NUMBER ordered by date, and subtract the number from the date. Days in the same streak give the same result, so grouping by user and that result gives each streak's start and end, and counting its rows gives the length. In the example, active days 1, 2, 3, 5, 6, 9 give the values 0, 0, 0, 1, 1, 3 and 3 streaks.
Why does subtracting ROW_NUMBER from the date work?+
Inside an unbroken run, each row moves the date forward by 1 and the row number forward by 1, so their difference stays constant. A gap makes the date jump while the row number rises by only 1, so the difference changes and a new group starts.
How do you find the longest streak per user?+
Compute the islands with the row number difference, then take the MAX of the streak length per user. Add a ROW_NUMBER over streak length descending when you also need the start and end dates of the longest one.
How do you find the gaps instead of the islands?+
Use LEAD to read the next date on each row and keep the rows where the next date is more than 1 day later. The gap starts the day after the current row and ends the day before the next one.
What if a user has several events on the same day?+
Use SELECT DISTINCT or a GROUP BY to get 1 row per user and day before numbering. ROW_NUMBER gives 2 rows on the same date different numbers, which breaks the constant difference and splits 1 streak into pieces.
Does gaps and islands work on timestamps and on non-date values?+
Yes. For consecutive integers such as ids, subtract the row number from the value itself. For timestamps, truncate to the day before numbering, or flag rows where the gap from LAG exceeds a limit and take a running sum of the flags.
02 / Why practice

The candidate who gets the offer

  1. 01

    Reading a solution is not the same as writing one

    Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you

  2. 02

    76% of hiring managers reject on the coding task, not the resume

    From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice

  3. 03

    5 problem shapes cover 80% of data engineer loops

    Dedup, sessionization, top-N-per-group, slowly-changing dimensions, partition tricks. Writing the shapes by hand turns the unfamiliar into pattern recognition

Related guides