Gaps and Islands: Beginner
You are 20 minutes into a SQL screen at a fitness app. The interviewer shares a table on screen: user_id, workout_date, roughly 3 million rows.
"Find each user's longest run of consecutive workout days."
Then he waits. The sample shows one user with dates March 1, 2, 3, 5 and 6, and it reads like a sorting problem. Most candidates start typing a self-join that matches each row to the day before it, then stall on turning those matches into groups, or a recursive CTE that walks forward 1 day at a time. Both look fine on 5 rows, and the cost only appears at 3 million. What the question never says is that SQL has no operator for "consecutive", so a run has to be turned into a column that a GROUP BY can see. He is listening for whether you can name that column before you write a line.
What you will be able to do
Every phrasing depends on the neighbouring rows
Each of these phrasings asks about a group of rows that is defined by what the rows next to it look like. A user's 3-day streak is a streak because the 3 dates sit side by side with nothing missing between them. Remove the middle date and the same 2 remaining rows become 2 streaks of 1. That dependence on neighbours is the mark of the pattern: no single row carries enough information to say which group it belongs to, so a WHERE clause cannot find the group and a plain GROUP BY has nothing to group on.
The operator SQL does not have
SQL has no keyword for "consecutive". GROUP BY collapses rows that share a value in a column, and a table of dates has no column whose value is shared along a streak. The database sees 1, 2, 3, 7, 8, 12 as 6 numbers with no idea that the first 3 belong together. The whole pattern is the work of inventing that column: a value that is identical for every row in a run and different for rows in other runs, computed from the order of the rows, so that an ordinary GROUP BY can finish the job.
The sentence to say before typing
- ▸"This is a gaps-and-islands question: the rows form runs, and I need a group key that is shared by every row in a run."
- ▸"I will number each user's rows with
ROW_NUMBER()ordered by the date." - ▸"Then I subtract the row number from the date, which gives a value that holds still inside a run."
- ▸"Inside a run both climb by 1 per row, so the difference holds; at a gap the date jumps and the difference changes."
- Writes a recursive CTE that walks the table 1 row at a time
- Writes a correlated subquery comparing each row to its neighbours
- Looks for a built-in consecutive-run function that does not exist
- Spends 10 minutes unable to express the grouping and runs out of time
- Names the pattern out loud before the first keystroke
- Numbers each user's rows with ROW_NUMBER(), restarting per user and sorted by date
- Computes value minus row number and groups by that difference
- Gives the 2-sentence proof: lockstep inside a run, divergence at a gap
Where the same question lives at work
An island is a maximal stretch of rows with no break between any 2 neighbours. A gap is the empty space between 2 islands: the values that would have continued the sequence and are missing. In the sequence 1, 2, 3, 7, 8, 12 there are 3 islands and 2 gaps, and the gaps are made of values that appear in no row at all, which is the part that trips people up. You cannot SELECT a gap, because the gap has no rows; the only thing the query can do is find where each island stops.
| n | next value present | what sits between | verdict |
|---|---|---|---|
| 1 | 2 | nothing | island continues |
| 2 | 3 | nothing | island continues |
| 3 | 7 | 4, 5, 6 missing | island ends, gap of 3 |
| 7 | 8 | nothing | island continues |
| 8 | 12 | 9, 10, 11 missing | island ends, gap of 3 |
| 12 | none | end of data | island of 1 row |
The question defines the break
| question wording | sequence column | expected step | a break is |
|---|---|---|---|
| longest login streak | login_date | 1 day | a calendar day with no login |
| contiguous invoice ranges | invoice_no | 1 | a missing number |
| unbroken hourly readings | reading_hour | 1 hour | an hour with no reading |
| consecutive booked nights | night | 1 day | an unbooked night |
Counting what is missing
Islands of 1 and the edges of the data
HAVING COUNT(*) > 1, and an interviewer hears the question as evidence that you know the output row count depends on it.Saying the definition out loud
A strong answer states the 3 facts the definition pins down before any SQL: what the sequence column is, what the expected step is, and whether islands of 1 belong in the output. "The sequence is login_date, the step is 1 day, and a single login is a streak of 1 unless you tell me otherwise." Every later choice, including the exact arithmetic of the group key and the aggregates at the end, follows from those 3 facts, so stating them first lets the interviewer check your plan before you commit to it.
- ▸Sequence column: which column the rows are in order by (a date, an id, an hour).
- ▸Expected step: the distance between neighbours when nothing is missing (1, 1 day, 1 hour).
- ▸Minimum island: whether 1 row alone counts, which decides if the output needs a
HAVINGfilter.
A table has no order. The rows of logins come back in whatever order the storage engine finds convenient, and 2 runs of the same query can return them differently. A run is a statement about which row comes after which, so until the query has imposed an order on the rows inside each user there are no runs to find: there is a bag of dates. ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) is the step that turns the bag into a sequence, and every later step depends on it being right.
| user_id | login_date |
|---|---|
| bob | 2024-03-02 |
| alice | 2024-03-05 |
| alice | 2024-03-01 |
| bob | 2024-03-03 |
| alice | 2024-03-03 |
| alice | 2024-03-02 |
Numbering across users is the bug
PARTITION BY is mandatory" as you write it. An interviewer who has watched this query fail knows the missing partition is the number 1 cause, and hearing you name it closes that rubric line.What ORDER BY inside OVER promises
The ORDER BY inside the OVER clause is the only one that matters to the run. It tells the engine the sequence the row numbers follow, within each partition, independent of how the rows are stored and of any ORDER BY at the end of the query. The final ORDER BY arranges the output for a human and has no effect on the numbering. Two different orders can appear in 1 query without conflict: number by login_date, then sort the result by streak_length DESC.
The window runs after the WHERE clause, so a filter such as only mobile logins is applied before the numbering and the row numbers count only the kept rows. Apply the same filter in an outer query after the numbering and the removed rows take their row numbers with them: if Alice's March 2 login came from a laptop, March 1 keeps row number 1 and March 3 keeps row number 3, both give the same difference, and 2 days that are not consecutive merge into 1 streak. The row number skips exactly where the date skips, which hides the gap, so every filter on which rows take part belongs before the window, in the same ranked stage.
- ▸
PARTITION BYthe entity whose runs you want: user, room, sensor, account. - ▸
ORDER BYthe sequence column ascending insideOVER. - ▸Treat the outer
ORDER BYas display only; it cannot repair a wrong window order. - ▸Put every row filter before the window so the numbering has no holes.
When the interviewer asks "what if 2 users log in on the same date", the answer is that the partition keeps them apart: Alice's March 2 and Bob's March 2 each take the number their own sequence gives them. The trouble starts when a single user has 2 rows on the same date, which the next lesson in this track covers in full. Here, say that you would use SELECT DISTINCT user_id, login_date to reduce the table to 1 row per user per day before numbering, then move on.
SELECTn,ROW_NUMBER() OVER (ORDER BY n) AS rn,n - ROW_NUMBER() OVER (ORDER BY n) AS island_idFROM sequence_data
Inside a run n and rn both step by 1, so n minus rn stays constant until a gap
Watch the 6 rows of sequence_data, which hold 1 through 3, then jump to 7 and 8, then to 12. Row numbers land as 1 through 6. n - rn gives 0 on each of the first 3 rows and 3 on the rows for 7 and 8; the row for 12 gets 6. That is 3 distinct differences and 3 islands. The gap from 3 to 7 skipped 3 values and the difference moved from 0 to 3; the gap from 8 to 12 skipped 3 more and it moved from 3 to 6. The size of each jump in the key equals the number of missing values, which is a useful check when you trace by hand.
The proof in 2 sentences
The whole query
| island_id | island_start | island_end | island_length |
|---|---|---|---|
| 0 | 1 | 3 | 3 |
| 3 | 7 | 8 | 2 |
| 6 | 12 | 12 | 1 |
Read the CTEs top down and they name the steps: ranked numbers the rows, grouped computes the key, and the final SELECT collapses each key into 1 row with MIN for the start, MAX for the end and COUNT(*) for the length. Point at island_id when you trace it and say "this column is identical for every row in a run, which is what lets GROUP BY fold the run into 1 row". The output has as many rows as there are islands, 3 here, never as many as the input.
COUNT(*) and MAX(n) - MIN(n) + 1 both give 3 for the first island, and the interviewer may ask which you would use. On clean data they always agree, so pick COUNT(*) because it reads as "rows in the run". Neither one catches a duplicated value: in 1, 2, 2, 3 the second 2 takes its own row number, the differences come out 0, 0, -1, -1, and the run splits in 2, with an island from 1 to 2 overlapping one from 2 to 3. Count and span both say 2 for each. The defence is the SELECT DISTINCT from section 2, which leaves 1 row per value before the numbering starts.
The label means nothing on its own
The keys in the example (0, 3, 6) are neither run lengths nor run starts, and no consumer should ever see them: they only happen to stay constant along a run. Drop island_id from the final SELECT in production and keep it only while debugging. Two users can produce the same label for unrelated runs, since each user's row numbers restart at 1, which is why the GROUP BY always carries the entity column beside the key; the next lesson in this track works through that pairing, and here the rule is to never group by the key alone when there is more than 1 entity.
Volunteer the caveat about the label unprompted: it tells the interviewer you derived the trick rather than copied it.
The same trick on dates
A date minus an integer needs the integer turned into days, so the subtraction becomes login_date - rn * INTERVAL '1 day', and the key is a date that stays the same along a streak. Alice's logins from March 1 to March 3 carry row numbers 1 to 3, so every one of them gives February 29, while her March 5 with row number 4 gives March 1, a different key. The query returns a 3-day streak and a 1-day streak for her and a 2-day streak for Bob. Everything else about the query is identical, and the calendar details of the date form are the next lesson's subject.
The follow-up is always "now give me the longest one per user". Wrap the streaks in a CTE and take MAX(streak_length) grouped by user_id for the number alone; returning the dates of that streak means picking a row, and the tie between 2 equal streaks is the awkward case the next section sketches.
| n | rn | n - rn | island |
|---|---|---|---|
| 1 | 1 | 0 | A |
| 2 | 2 | 0 | A |
| 3 | 3 | 0 | A |
| 7 | 4 | 3 | B |
| 8 | 5 | 3 | B |
| 12 | 6 | 6 | C |
| 13 | 7 | 6 | C |
| 14 | 8 | 6 | C |
| 20 | 9 | 11 | D |
The awkward cases the sketch shows
The tie is the one that costs offers. ORDER BY island_length DESC LIMIT 1 returns A or C depending on which the engine happens to place first, and an interviewer who built the example put the tie in on purpose. Ask "should ties at the longest length both come back, or do you want exactly 1 row?" If both, filter the runs to the ones whose length equals the maximum, as the query below does; if 1, add a deterministic tiebreak such as the earliest start to the ORDER BY before the LIMIT.
SELECT*FROM runsWHERE island_length = (SELECTMAX(island_length)FROM runs)
Starting from the runs, the subquery finds the longest length and every run matching it stays
runs here is the output of section 3's query over the 9 values above, 1 row per island. The scalar subquery finds that the largest length is 3, and WHERE keeps every run that long, so the runs starting at 1 and at 12 both come back while 7 to 8 and 20 drop out. With 1 run alone at the top, the same query returns 1 row, so it is correct in both cases without changing anything.
A 60-second routine
- ▸Write the sequence values in order, 6 to 9 of them, including 1 gap of more than 1 value and 1 island of 1 row.
- ▸Number them 1 to N and write the difference beside each.
- ▸Letter the islands where the difference holds, and count them.
- ▸Check the letters against the question's definition of a break.
- ▸Name the awkward case your example contains, and say it to the interviewer before they ask.
When the interviewer hands you a sample with 2 users, sketch each user separately, because the partition is what the sketch is checking. A sketch that numbers both users in 1 column reproduces the missing-partition bug by hand, and watching a letter change in the middle of a real streak on the whiteboard is the fastest way to show why the query needs PARTITION BY. If the sample has a user with 1 login, trace that user too: an island of 1 row is where most "too many rows" complaints come from, and it takes 1 line to show that the query is right and the question was underspecified.
The sketch also tells you what to type last. Once the letters are down, the aggregates write themselves: the smallest value under each letter is the start, the largest is the end, and the count of rows is the length. If the question asked for a minimum streak of 2, cross out D on the sketch and add the HAVING that does the same in the query. Everything the interviewer sees on the board matches a line in the SQL, which is what makes the trace convincing.
- Say "gaps and islands" and the subtraction sentence before writing a line
- Pin down the sequence column and its step, and ask whether an island of 1 counts
- Number with ROW_NUMBER() partitioned by entity, ordered ascending by the sequence
- Trace a 6-to-9 row sketch by hand that includes a gap and a 1-row island
- Ask how ties for the longest run should be returned
- Reach for a self-join or recursive CTE to walk neighbouring rows
- Number across all entities with 1 window and trust the result
- Show the n - rn label in the output as if it meant something
- Use LIMIT 1 for the longest run without a tiebreak or a question
> A subscription software company bills monthly, and the finance team keeps 1 row per customer per month in which an invoice was paid. The CFO asks for each customer's longest unbroken stretch of paid months, to report tenure to the board, and flags that customers who paused and returned must show their stretches separately, while a customer who paid for 1 month still counts.
Recognise the streak question under any wording, and build the one column that lets GROUP BY find every unbroken run.
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: Spotting a Question About Runs, What Counts as a Gap vs an Island, Ordering Rows Before Grouping, Why the Difference Trick Works, Sketching the Sequence by Hand
Lesson Sections
- Spotting a Question About Runs (concepts: Row Numbering, Grouping)
The question rarely contains the word "island". It arrives as "find the longest streak of login days per user", or "show me each stretch of nights the room was booked", or "which invoice numbers are present in contiguous ranges". The data is 1 row per event or per value, and the answer is 1 row per unbroken stretch, which means the query has to collapse many rows into 1 without any column that says which rows belong together. Every phrasing depends on the neighbouring rows The operator SQL does
- What Counts as a Gap vs an Island
The question defines the break What counts as a break is set by the question, and the first job is to pull that definition out of the wording. "Consecutive days" means the expected step is 1 day and anything larger is a break. "Contiguous invoice numbers" means the step is 1. "Consecutive hours of readings" means 1 hour. The difference trick in section 3 works for any sequence that is supposed to step by a fixed amount, because it subtracts the position from the value, and a fixed step is what m
- Ordering Rows Before Grouping (concepts: Row Numbering, Window Partitioning, Sorting Results)
Alice logged in from March 1 to March 3 and again on March 5, and Bob on March 2 and 3. Read as stored, nothing about these 6 rows says that Alice's first 3 dates belong together, and a row number handed out in storage order would mean nothing. The pattern needs the rows ordered by date within each user, and the query has to build that order itself. Numbering across users is the bug On the left, Alice's March 3 login carries row number 4 because Bob's March 2 login took number 3. Her dates go up
- Why the Difference Trick Works (concepts: Row Numbering, Complex Data Patterns, Grouping, Common Table Expressions, Min & Max)
With the rows numbered inside each entity, the island key is 1 subtraction. Take the sequence value and subtract the row number. Along an unbroken run the value climbs by 1 per row and the row number climbs by 1 per row, so the 2 move in lockstep and their difference holds still. At a gap the value jumps by more than 1 while the row number still climbs by 1, so the difference jumps by the size of the gap and never returns to its old value, because both columns only ever increase. The proof in 2
- Sketching the Sequence by Hand
Before writing the query in the room, draw the sequence. Put the value in the first column and the row number in the second; the third holds their difference and the fourth a letter naming the island. With 9 rows this takes roughly 60 seconds. The sketch catches every mistake this pattern produces, because a wrong partition shows up as a letter that changes where it should not, and a wrong direction or a misread step does the same. It also gives you and the interviewer one picture to point at wh