skip to content

Pattern: Gaps and Islands

Gaps-and-islands questions — consecutive login streaks, missing sequence numbers, contiguous date ranges — are the hardest common window-function interview problems. The trick is the row_number-difference technique that gives every consecutive run a constant group key.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

5

Why does value minus ROW_NUMBER() give every run of consecutive values a constant key?

level: middleimportance: must knowfreq 55%

answer

  1. both quantities climb at the same rate
  2. subtracting cancels a constant climb
  3. a jump in the value is not matched by the counter
  4. the difference is a synthetic run id
  5. duplicates shift the numbering — DENSE_RANK fixes it

basics

~20 s

Within a run, the value and the row number both increase by exactly one per row, so their difference is constant; at a gap the value jumps ahead while the row number does not, so the difference changes. Grouping by that difference collapses each run.

solid answer

~50 s

`ROW_NUMBER() OVER (ORDER BY v)` produces 1, 2, 3, … over the ordered rows. Inside an island the value also steps by one, so `v - ROW_NUMBER()` stays the same for every row of that island. When the sequence skips ahead, the value gains more than the row number does and the difference increases, so the next island gets a different constant. That difference is a synthetic run id — its numeric value is meaningless, only its constancy matters — so you compute it in a CTE and then `GROUP BY` it with `MIN(v)`, `MAX(v)` and `COUNT(*)` to report each island. Three conditions must hold: the step is exactly one, the values are unique (duplicates shift the row numbers and split a run — use `DENSE_RANK()` instead), and any per-entity islands need `PARTITION BY` in the `OVER` clause plus that key in the `GROUP BY`.

code

sql · 6 lines
sql
SELECT reading_no,
       ROW_NUMBER() OVER (ORDER BY reading_no) AS rn,
       reading_no - ROW_NUMBER() OVER (ORDER BY reading_no) AS island_key
FROM sensor_readings
ORDER BY reading_no;
-- reading_no 1,2,3,7,8,11 -> rn 1..6 -> island_key 0,0,0,3,3,5

go deeper

for a junior

Recall the shape of the expression — the ordered value minus ROW_NUMBER() over the same order — and that you group by the result to get one group per run.

for a middle

Derive the constancy out loud: both quantities climb by one inside a run, so the difference cancels. Then state the preconditions — unique values, step of exactly one, ordering by the sequencing column.

for a senior

Demonstrate you would check the data first: duplicates, per-entity partitioning, and whether the sequence is defined over the filtered result set. Be ready to switch to DENSE_RANK or to a LAG-based break flag and say why.

for a principal

Own the readability question — this idiom is invisible to anyone who has not seen it, so wrap it in a well-named view or CTE with a comment, and keep one agreed definition of a run rather than five hand-rolled variants across reports.

## The construction ```sql SELECT reading_no, reading_no - ROW_NUMBER() OVER (ORDER BY reading_no) AS island_key FROM sensor_readings; ``` For `reading_no` values 1, 2, 3, 7, 8, 11 the row numbers are 1…6 and `island_key` comes out as 0, 0, 0, 3, 3, 5. Three distinct keys, one per island. ## Why it works Walk two adjacent rows of the same island. The row number always increases by exactly one — that is what `ROW_NUMBER()` does over an ordered set. Inside an island the value also increases by exactly one, by the definition of consecutive. Two quantities that both increase by one keep a constant difference, so every row of a run carries the same `island_key`. At a gap the value jumps by more than one while the row number still advances by one, so the difference grows by exactly the size of the gap. That guarantees the new island's key differs from the previous one. It also guarantees keys never repeat: the difference is non-decreasing as you scan forward and strictly increases at each break, so two different islands can never collide on the same key. The number itself carries no meaning — do not present it to users; it is scaffolding for `GROUP BY`. ## Turning it into an answer ```sql WITH marked AS ( SELECT reading_no, reading_no - ROW_NUMBER() OVER (ORDER BY reading_no) AS island_key FROM sensor_readings ) SELECT MIN(reading_no) AS island_start, MAX(reading_no) AS island_end, COUNT(*) AS island_length FROM marked GROUP BY island_key ORDER BY island_start; ``` The window function must be computed in an inner query: window functions are evaluated after `WHERE` and `GROUP BY`, so you cannot group by a window expression in the same query block. A CTE or derived table is the standard way to stage it. ## The three preconditions **Step of exactly one.** The trick relies on the value advancing at the same rate as the row number. Values that step by 2, or ids drawn from a sequence with caching holes, break it — every real hole becomes an island boundary whether or not it is one. If the step is a constant *k*, divide the value by *k* first, or switch to the `LAG()`-based construction where you spell out the break condition explicitly. **Uniqueness.** Duplicates are the classic trap. With values 4, 4, 5, 6 the row numbers are 1, 2, 3, 4 and the differences are 3, 2, 2, 2 — the duplicate splits what should be a single island. Two fixes: deduplicate first with `SELECT DISTINCT`, or use `DENSE_RANK() OVER (ORDER BY v)` instead of `ROW_NUMBER()`, which assigns the same rank to equal values and yields 3, 3, 3, 3. `DENSE_RANK()` is the safer default whenever duplicates are possible. **Deterministic ordering.** The `ORDER BY` inside `OVER` must order by the value that defines consecutiveness. Ordering by anything else produces row numbers unrelated to the sequence and the differences become noise. ## Per-entity islands Streaks are almost always per user, per device, per account. Add the partition key in both places: ```sql ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY reading_no) ... GROUP BY user_id, island_key ``` Forgetting the key in the `GROUP BY` is a real bug rather than a style issue: two different users can produce the same difference and their rows would be merged into one bogus island. ## Filtered sets are a feature The row numbers are assigned over the rows that survive `WHERE`, not over the whole table. That is usually exactly what you want — "runs of days where the balance was negative" means numbering only the negative-balance days, so the positive days genuinely become gaps. It is worth stating out loud in an interview, because it shows you understand that the sequence is defined by the query's result set, not by the physical table. ## When to reach for the other construction The difference trick is compact and cheap but it can only express "the next value is exactly one step away". As soon as the break condition involves a tolerance ("a new run if more than 30 minutes elapsed"), a value change ("a new run when the status changes"), or an irregular step, you want `LAG()` to flag the break and a running `SUM()` of that flag as the run id. Both produce a run id; only the second lets you define the break yourself.

  • Why must the difference be computed in a subquery or CTE rather than used directly in GROUP BY?
    Window functions are evaluated after `WHERE`, `GROUP BY` and `HAVING` in the logical processing order, so a query block cannot group by its own window expression. Staging the expression in a CTE or derived table makes it an ordinary column of the outer query, which can then be grouped like any other.
  • The values step by exactly 5 rather than 1. Does the trick still work?
    Not as written — the difference would change at every row. Either divide the value by the step first, so `v / 5 - ROW_NUMBER()` is constant inside a run, or drop the trick and use `LAG()` with an explicit break condition such as `v - LAG(v) OVER (ORDER BY v) <> 5`, which states the rule instead of relying on arithmetic coincidence.
  • Does the island key mean anything you can show a user?
    No. It is an arbitrary offset that happens to be stable within a run; it changes if you filter differently or add rows before the first island. Report `MIN`, `MAX` and `COUNT` per group instead, or renumber the islands with `DENSE_RANK() OVER (ORDER BY island_key)` in an outer query if you need 1, 2, 3.

Two runners on a track, one a fixed distance behind the other. As long as both take one step per beat the gap between them never changes; the moment one leaps forward, the gap widens permanently — and the size of that gap labels the stretch that follows.

saying these in an interview costs you the question

  • Says the difference value itself has meaning
  • Uses ROW_NUMBER on a column that contains duplicates
  • Groups by the island key without the partition column
  • Tries to GROUP BY the window expression in the same query block
  • Assumes the trick works for any step size, not just one

context

open as a page

How do you compute each user's longest streak of consecutive daily logins in SQL?

level: middleimportance: should knowfreq 45%

basics

~20 s

Reduce logins to one row per user per day, number those days per user with ROW_NUMBER() ordered by date, subtract that many days from the date, group by user plus that shifted date to get each streak, then take the maximum streak length per user.

open as a page

How do you report the missing ranges in a sequence of invoice numbers using LEAD()?

level: middleimportance: should knowfreq 35%

basics

~20 s

Order the existing values and give each row the next one with LEAD(); keep rows where the next value is more than one step ahead. Each surviving row yields a gap from current + 1 to next - 1. Gaps outside the min and max need explicit bounds.

open as a page

How do you group event rows into sessions that break after 30 minutes of inactivity?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Compare each row with the previous one using LAG(); emit 1 when the elapsed time exceeds the threshold and 0 otherwise, then take a running SUM() of that flag in the same order. The cumulative sum is a session id you can GROUP BY.

open as a page

In SQL, what is the gaps-and-islands problem, and what counts as an island?

level: juniorimportance: nice to knowfreq 22%

basics

~20 s

Gaps and islands names a family of SQL problems over an ordered column: islands are maximal runs of consecutive values, gaps are the stretches missing between them. The work is inventing a group key per run.

open as a page