Knowledge Guide
HomeDatabasesSQL Practice Problems

SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)

Five different practice problems — active users, consecutive login streaks, attendance runs, session windows, activity-day counts — all reduce to the same underlying shape: find maximal runs of consecutive rows in an ordered sequence. Each one is usually taught with its own bespoke trick, which means a learner who has solved four of them still freezes on the fifth because it doesn't look like the others. This page teaches the one canonical mechanism underneath all five — the ROW_NUMBER()-difference trick — so that "gaps and islands" becomes a single recognized pattern instead of five memorized recipes. It then covers the two mechanisms that most often ride alongside it in the same problem set: the DENSE_RANK() n-th-value generalization (with its tie-for-fastest and determinism subtleties), and the zero-denominator / zero-result behavior that silently turns a rate calculation into NULL instead of 0. It continues the SQL Practice Patterns I (NULL three-valued logic, COUNT variants, sargability, missing-group joins) and SQL Practice Patterns II (window-function ties, WHERE-vs-ON placement, self-join fan-out, portability) companions one level further — Patterns II already introduced DENSE_RANK vs RANK vs ROW_NUMBER for ties, and touched a calendar-day version of gaps-and-islands for High School Attendance; this page makes gaps-and-islands the headline topic and generalizes it past that one problem.

1. Gaps-and-islands: the ROW_NUMBER-difference trick

Mechanism first: take any sequence ordered by a value that is supposed to increase by a fixed step per row (a date incrementing by one day, an integer ID incrementing by one). Within one unbroken run of that sequence, both the value and its ROW_NUMBER() (ordered the same way) advance by exactly one per row — so value − ROW_NUMBER() stays constant for every row in that run. The instant a row is missing (a gap), the value jumps ahead by more than one step while ROW_NUMBER() only ever advances by one — so the difference jumps too, and never returns to its old value again. GROUP BY that difference therefore partitions the rows into exactly the maximal consecutive runs ("islands"), with no explicit loop, recursive CTE, or self-join required.

Traced worked example — consecutive login days

A user logs in on days 1, 2, 3, then skips day 4, then resumes on days 5 and 6 — two islands of consecutive activity, separated by one missing day:

login_dayROW_NUMBER() OVER (ORDER BY login_day)login_day − ROW_NUMBER()island
110A
220A
330A
541B
651B

Day 4 has no row, so ROW_NUMBER() for day 5 is 4, not 5 — it only counted the rows that actually exist. 5 − 4 = 1, a new value distinct from island A's 0. GROUP BY the difference column recovers both islands directly: GROUP BY (login_day − rn) with MIN(login_day) and MAX(login_day) per group gives island A as days 1–3 and island B as days 5–6, with the run length available for free as COUNT(*) per group.

-- Canonical gaps-and-islands template
WITH numbered AS (
    SELECT login_day,
           ROW_NUMBER() OVER (ORDER BY login_day) AS rn
    FROM logins
)
SELECT MIN(login_day) AS island_start,
       MAX(login_day) AS island_end,
       COUNT(*)        AS run_length
FROM numbered
GROUP BY (login_day - rn)
ORDER BY island_start;

The same template answers every member of this family: "3+ consecutive absent days" (filter to status='Absent' before numbering, then HAVING COUNT(*) >= 3), "longest login streak" (ORDER BY run_length DESC LIMIT 1), "total number of active-day streaks" (COUNT(*) over the grouped result), "team's current win streak" (partition the numbering by team first). One mechanism, five surface problems.

The robustness caveat — this only works on a dense, gap-free sequence

The trick's correctness depends on a hidden assumption: that consecutive rows in the intended sense are exactly one unit apart in the ordering value. It works cleanly for integer IDs and for calendar dates where every possible day could plausibly have a row. It breaks silently when the domain isn't a dense calendar — e.g. attendance recorded only on school days (no rows for weekends), where a Friday-Monday-Tuesday absence run is 3 consecutive school days but the raw dates jump by 3, not 1, across the weekend. Subtracting ROW_NUMBER() from the raw date then produces a different value on each side of the weekend even though the run is genuinely unbroken, and the run silently splits into two islands. Two fixes, by data shape:

The interviewer probe that tests whether a candidate actually understands the mechanism, rather than having memorized the query: "what if a day is missing from the underlying data?" The correct answer names which of the two fixes applies, and why — not just "add a WHERE clause."

Consecutive login dates with a gap at day 4: ROW_NUMBER minus date stays constant within each island and jumps at the gap, so GROUP BY that difference recovers the two islands
Consecutive login dates with a gap at day 4: ROW_NUMBER minus date stays constant within each island and jumps at the gap, so GROUP BY that difference recovers the two islands

2. DENSE_RANK for the n-th value, generalized: tie-for-fastest and determinism

Mechanism first: "the Nth-highest value" and "the top-N values" are the same question once ties are accounted for — both ask "how many distinct values have I seen so far, ordered a certain way?" DENSE_RANK() answers exactly that: it assigns the same rank to every row that ties, and does not skip ranks afterward, so filtering WHERE rnk <= N or WHERE rnk = N returns every row belonging to the Nth distinct value — not just one arbitrarily chosen representative. (The Patterns II companion traces why ROW_NUMBER() and RANK() both give the wrong answer to this same question — ROW_NUMBER by picking one arbitrary tied row, RANK by skipping ahead so no row may match rnk = N at all.)

Traced example — tie-for-fastest

Four racers, finish times ascending (fastest first): 9.58, 9.58, 9.69, 9.71 — two racers tie for the fastest time.

SELECT racer, finish_time,
       DENSE_RANK() OVER (ORDER BY finish_time ASC) AS rnk
FROM race_results;

WHERE rnk = 1 correctly returns both tied racers — the "who is fastest" question has two right answers when there is a genuine tie, and DENSE_RANK is the only ranking function whose filter semantics reflect that. Crucially, DENSE_RANK also gets the next question right without any special-casing: WHERE rnk = 2 ("2nd-fastest") lands on 9.69, the next genuinely distinct time — not a duplicate copy of 9.58. This is the top-N / Nth-highest-with-ties generalization: the same DENSE_RANK mechanism that resolves "2nd highest salary" for two values also resolves "top-3 distinct price tiers" or "the top 5 highest-scoring, possibly-tied students" — swap the filter from an equality (= N) to a range (<= N) and both tied groups and single groups are handled uniformly.

The determinism trap: "which row" is arbitrary without a tiebreaker

Getting the rank right for tied rows does not mean the row-level output is deterministic. If a query asks for "the fastest racer" as a single row (ORDER BY finish_time LIMIT 1, or DENSE_RANK() ... WHERE rnk = 1 LIMIT 1) and two racers tie, the engine is free to return either one — and, without an explicit tiebreaker, may return a different one on a re-run, a different server replica, or after an unrelated index change. The fix is not a different ranking function; it's adding a deterministic column to ORDER BY:

SELECT racer, finish_time
FROM race_results
ORDER BY finish_time ASC, racer ASC   -- deterministic tiebreaker
LIMIT 1;

Named alternative — correlated subquery with COUNT(DISTINCT ...): the Nth-highest-distinct-value question can also be answered without any window function:

-- Nth-highest DISTINCT salary via a correlated subquery
SELECT DISTINCT e1.salary
FROM Employee e1
WHERE (
    SELECT COUNT(DISTINCT e2.salary)
    FROM Employee e2
    WHERE e2.salary > e1.salary
) = N - 1;

This counts how many distinct salaries exceed each candidate; a salary is the Nth-highest exactly when N−1 distinct salaries beat it. It is portable to engines with no window-function support, but it re-scans the table once per outer row (O(n²) without a supporting index) versus one sorted pass for the window-function version — a real cost trade-off on a large table, not just a stylistic choice. Note that DISTINCT cannot be combined with a window function's OVER clause on any mainstream engine (PostgreSQL, SQL Server, Oracle, and MySQL all reject COUNT(DISTINCT ...) OVER (...) as a syntax/semantic error) — there is no additional frame-based form that adds DISTINCT inside OVER. DENSE_RANK() OVER (ORDER BY salary DESC) itself already is the windowed running-distinct-count, which is exactly why it's the standard window-function answer to this question rather than a variant of the correlated subquery above.

DENSE_RANK over race finish times: two racers tie for the fastest time and both qualify as rank 1; asking for the 2nd-fastest via rank=2 lands on the correct next distinct time
DENSE_RANK over race finish times: two racers tie for the fastest time and both qualify as rank 1; asking for the 2nd-fastest via rank=2 lands on the correct next distinct time

3. Zero-trip-day / zero-result semantics: NULL is not 0

Mechanism first: a rate or ratio computed with AVG(), or a manual division like SUM(x) / SUM(y), is only well-defined when the group being aggregated actually has rows. Aggregate functions in SQL run per group after grouping; when a group has zero rows to aggregate, AVG and SUM over that empty set return NULL — not 0 — because "the average of nothing" is undefined, not zero. This is the single most common source of a wrong "0" or a wrong blank row in ratio-style interview problems: the query author assumes an empty group prints 0, but SQL prints NULL unless the query explicitly coalesces it.

NULL counterexample — "Trips and Users" / average ride-request rate

Consider a daily "average completed-trip rate" report: for each date, completed / (completed + cancelled). Suppose date 2020-01-02 has zero total requests logged at all (a true zero-trip day — no rows exist for that date in the requests table in the first place):

-- WRONG on a day with no rows at all: this date is simply MISSING from the result,
-- not present with a 0 -- an aggregate query can only report on groups that have rows
SELECT request_date,
       AVG(CASE WHEN status = 'cancelled' THEN 1.0 ELSE 0 END) AS cancel_rate
FROM trips
GROUP BY request_date;

A day with zero rows in trips never appears in this result at all — GROUP BY can only group rows that exist, so a zero-activity day is silently absent from the report rather than present with a 0 or NULL rate. If the requirement is "report every day in a date range, including days with no activity," the fix is a driving calendar table or date-generation series LEFT JOINed to the activity table (the same missing-group pattern the Patterns I companion covers for INNER-vs-LEFT+COALESCE), so every date row exists before aggregation even runs. Separately, even on a day that does have rows but zero rows in one branch of a ratio — e.g. zero cancellations that day — SUM(cancelled)/SUM(total) correctly evaluates to a real 0 because SUM over an existing, non-empty group of all-zero values is 0, not NULL; the NULL danger is specifically about the denominator itself being computed over zero rows or being a literal zero, not about the numerator happening to be zero.

Division semantics: integer truncation, ROUND, and NULLIF

Three separate failure modes stack on top of the empty-group issue:

-- Full pattern: calendar-anchored, NULL-safe, correctly rounded, explicit divide-by-zero guard
SELECT d.request_date,
       ROUND(
         COALESCE(SUM(CASE WHEN t.status = 'cancelled' THEN 1.0 ELSE 0 END), 0)
         / NULLIF(COUNT(t.trip_id), 0), 2) AS cancel_rate
FROM date_series d
LEFT JOIN trips t ON t.request_date = d.request_date
GROUP BY d.request_date
ORDER BY d.request_date;

Here COUNT(t.trip_id) (not COUNT(*)) is 0 for a calendar day joined against no trip rows — NULLIF(0, 0) converts that 0 denominator to NULL, and any division by NULL is NULL, cleanly representing "no rate is defined for this day" instead of a spurious 0 or a divide-by-zero error — while the date_series/LEFT JOIN keeps the zero-activity day present in the output in the first place.

4. Query-plan note: window functions need a sort

Mechanism first: every window function used above — ROW_NUMBER(), DENSE_RANK() — must process rows in the order given by its PARTITION BY/ORDER BY clause, and the engine typically satisfies that by sorting the input first (or by consuming an index that is already ordered that way). On an unindexed table this is an explicit sort step in the query plan, costing O(n log n) and, for a set larger than available working memory, a disk spill. A composite index on (partition_columns, order_columns) — e.g. (student_id, attendance_date) for a per-student gaps-and-islands query, or (finish_time) for the racer ranking — lets the engine walk the index in already-sorted order and skip the sort step entirely. This is the same cost mechanics covered in depth in the Query Execution — Selectivity, Cost Model, Join Algorithms, Spills & Sargability deep dive; the practical takeaway here is narrower: when a gaps-and-islands or ranking query is slow on a large table, check EXPLAIN for an explicit Sort/Using filesort node before assuming the window function itself is the bottleneck — it usually isn't; the missing supporting index is.

Pitfalls

Judgment layer: how a senior engineer decides

Takeaways

Related pages


Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)** (Databases) and want to truly understand it. Explain SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **SQL Practice Patterns III — Gaps-and-Islands (the Canonical Technique), DENSE_RANK n-th & Zero-Day Semantics (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes