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.
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.
Know Gaps and Islands the way the interviewer who asks it knows it.
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.


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
- Needs date arithmetic
- Break rule
- Window functions
- Duplicates
- Needs date arithmeticYes, or integers
- Break ruleExactly 1 step
- Window functions1 ROW_NUMBER
- DuplicatesRemove first
- Needs date arithmeticOnly for the limit
- Break ruleAny threshold
- Window functionsLAG and SUM
- DuplicatesTies share a number
- 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?+
How do you find consecutive days in SQL?+
Why does subtracting ROW_NUMBER from the date work?+
How do you find the longest streak per user?+
How do you find the gaps instead of the islands?+
What if a user has several events on the same day?+
Does gaps and islands work on timestamps and on non-date values?+
The candidate who gets the offer
- 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
- 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
- 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
SQL interview questions
The SQL questions asked in data engineering interviews
SQL practice problems
Window and streak problems with aggregation in a live editor
The SQL round
What the SQL round asks and how to prepare for it
SQL window functions interview questions
ROW_NUMBER and frames in interview form, along with LAG and LEAD
SQL window functions practice
Drills for the window functions this pattern is built on
Advent of Code in SQL
Puzzle problems that call for the same window and streak tricks
The data engineering challenge
Dirty, production-shaped data, scored blind each week.