skip to content

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

level: middleimportance: should knowfreq 35%

answer

  1. look ahead to the next existing value
  2. a gap sits between two rows that exist
  3. keep pairs more than one step apart
  4. report current + 1 through next - 1
  5. the final row's LEAD is NULL

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.

solid answer

~50 s

Gaps are the easy half of gaps and islands, because they live between two rows that both exist. `LEAD(invoice_no) OVER (ORDER BY invoice_no)` gives every row its successor; a row where `next_no > invoice_no + 1` sits immediately before a hole, and the hole runs from `invoice_no + 1` to `next_no - 1`. Filtering on that condition and projecting those two expressions produces one row per gap, with its bounds and — via `next_no - invoice_no - 1` — its size. The same query works on dates by comparing against `reading_date + INTERVAL '1' DAY`. Two limits to state up front: the last row's `LEAD()` is NULL so it drops out on its own, and the technique can only see holes *between* stored values — missing numbers below the minimum or above the maximum require the intended range to be supplied. Listing every missing value individually, rather than as ranges, needs a generated series or numbers table.

code

sql · 12 lines
sql
WITH bounds AS (
    SELECT invoice_no,
           LEAD(invoice_no) OVER (ORDER BY invoice_no) AS next_no
    FROM invoices
)
SELECT invoice_no + 1           AS gap_start,
       next_no - 1              AS gap_end,
       next_no - invoice_no - 1 AS missing_count
FROM bounds
WHERE next_no > invoice_no + 1
ORDER BY gap_start;
-- 1,2,5,6,10 -> gaps 3-4 (2 missing) and 7-9 (3 missing)

go deeper

for a junior

Recall the shape: LEAD() to see the next existing value, then keep rows where that next value is more than one step away, and report current + 1 through next - 1.

for a middle

Explain why no grouping is needed — a gap is fully described by the two rows bracketing it — and why the final row drops out on its own through the NULL comparison.

for a senior

Cover the limits: edges need supplied bounds, per-entity sequences need partitioning, dates need a calendar if weekends are legitimately absent, and holes in surrogate keys prove nothing about lost data.

for a principal

Frame what the report is evidence of before it is built. A contiguity check is only worth running where the sequence is business-controlled and someone acts on the result; otherwise it generates recurring false alarms.

## The query ```sql WITH bounds AS ( SELECT invoice_no, LEAD(invoice_no) OVER (ORDER BY invoice_no) AS next_no FROM invoices ) SELECT invoice_no + 1 AS gap_start, next_no - 1 AS gap_end, next_no - invoice_no - 1 AS missing_count FROM bounds WHERE next_no > invoice_no + 1 ORDER BY gap_start; ``` For invoice numbers 1, 2, 5, 6, 10 the pairs are (1,2), (2,5), (5,6), (6,10), (10,NULL). Only two rows satisfy the predicate: `2 → 5` yields the gap 3–4, and `6 → 10` yields 7–9. The last row is filtered out automatically because `LEAD()` returns NULL past the end of the partition and `NULL > 11` is unknown, which `WHERE` treats as not true. That is a genuinely useful consequence of three-valued logic — you do not need a special case for the final row. ## Why this is the mirror image of islands An island query needs a synthetic run id because the rows of a run must be collapsed together. A gap query needs no such thing: every gap is fully described by the pair of rows that bracket it, and `LEAD()` puts that pair on one row. No `GROUP BY`, no running sum — just a look-ahead and a filter. If an interviewer asks for both islands and gaps, the islands need the row-number trick and the gaps need only this. ## Dates instead of integers ```sql WITH bounds AS ( SELECT reading_date, LEAD(reading_date) OVER (ORDER BY reading_date) AS next_date FROM readings ) SELECT reading_date + INTERVAL '1' DAY AS gap_start, next_date - INTERVAL '1' DAY AS gap_end FROM bounds WHERE next_date > reading_date + INTERVAL '1' DAY; ``` The logic is identical; only the step changes. Date arithmetic is the least portable part, so check your engine's spelling. Note also that a gap here means *calendar* days: if weekends or holidays are legitimately absent, the query reports them as gaps unless you restrict the comparison to a calendar of expected days. ## What this technique cannot see **The edges.** Values missing before the smallest stored value or after the largest leave no bracketing pair, so they cannot be detected. If the question is "which numbers between 1 and 1000 were never issued", you must supply 1 and 1000 explicitly — for instance by unioning sentinel rows `0` and `1001` into the input so the first and last real values acquire neighbours, or by generating the intended range and anti-joining it against the table. **Individual values.** The output is ranges, which is usually what you want for a long sequence. Turning `3–4` into two rows `3` and `4` means producing values that are in no table, which needs a numbers table or a generated series; the portable route is a recursive `WITH` that counts from `gap_start` to `gap_end`, and several engines offer a built-in generator instead. **Duplicates and NULLs.** Duplicate values make `LEAD()` return the same value as the current row; the predicate is then false and the pair is harmlessly skipped, so duplicates do not create false gaps — but they do inflate the row count, and deduplicating first keeps the intent clear. NULLs in the ordering column are not values in the sequence at all; exclude them in `WHERE` rather than letting the engine's NULL ordering decide where they land. **Per-entity sequences.** If each branch or each year has its own numbering, add `PARTITION BY branch_id` to the `OVER` clause and carry `branch_id` into the output; otherwise the query reports a fake gap every time it crosses from one branch's range to the next. ## Interpreting the result A gap in an id sequence is not automatically evidence of missing business data. Identity columns and sequences are allowed to leave holes — rolled-back transactions, cached blocks and deleted rows all consume numbers permanently — so a gap report over a surrogate key usually says nothing about lost invoices. The report is meaningful when the numbering is a business-controlled sequence with a requirement to be contiguous, or when the column is a natural sequence such as a reading number or a page number. Saying this in an interview shows you understand what the query is evidence of, which is the difference between running the query and answering the question.

  • Why is the last row excluded without an explicit condition?
    `LEAD()` has no following row at the end of the partition, so it returns NULL. The predicate `NULL > invoice_no + 1` evaluates to unknown, and `WHERE` keeps only rows where the condition is true, so the row is dropped. No `IS NOT NULL` guard is needed, though adding one does no harm and documents the intent.
  • How would you also catch numbers missing before the smallest stored value?
    Give the smallest value a predecessor. Union a sentinel row holding `start_of_range - 1` into the input before applying `LEAD()`, and a matching sentinel `end_of_range + 1` for the tail. Alternatively generate the full intended range and anti-join it against the table, which handles both edges and reports individual values rather than ranges.
  • Does a gap in an auto-generated id column mean rows were deleted?
    Not necessarily. Identity columns and sequences consume numbers on rolled-back transactions and cache them in blocks, so holes appear without any row ever existing. Treat gap reports over surrogate keys as inconclusive; they are meaningful only where the numbering is a business sequence required to be contiguous.

saying these in an interview costs you the question

  • Expects the query to find numbers missing before the first row
  • Adds an IS NOT NULL check believing the last row otherwise appears
  • Reports individual missing values without generating a range
  • Forgets PARTITION BY when each entity has its own sequence
  • Treats holes in an identity column as proof of deleted rows

context