Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Writing SQL to Detect and Fill Sequence Gaps: Using Self-Joins and Subqueries to Find Missing IDs and Date Ranges

Sequence gaps — missing IDs, uncovered date ranges, holes in attendance records — hide data quality problems that standard queries never surface. This expert-level lesson teaches you every production-ready technique for finding and filling them, from self-joins to window functions, with full performance analysis.

🔥 Expert27 min readNov 3, 2026Updated Nov 3, 2026
Writing SQL to Detect and Fill Sequence Gaps: Using Self-Joins and Subqueries to Find Missing IDs and Date Ranges
On this page
  • Introduction
  • Prerequisites
  • The Two Flavors of Sequence Gaps
  • Finding Integer ID Gaps with a Self-Join
  • Understanding the Edge Case: Gap at the Beginning
  • The Reference Sequence Approach: Generate All Expected Values
  • Using NOT EXISTS for Gap Detection
  • Detecting Date Range Gaps: A More Complex Problem
  • The Lead-Row Pattern with Subqueries
  • Handling Overlaps: The Gap Detection Problem in Reverse
  • Generating the Missing Rows: Going Beyond Detection
  • Inserting Placeholder Records for Missing IDs
  • Filling Date Range Gaps with Derived Records
  • Advanced Pattern: Using Window Functions for Gap Detection
  • Performance Considerations and Index Strategy
  • Indexing for Integer Gap Detection
  • Indexing for Date Range Gap Detection
  • Partitioning and Batch Strategies for Very Large Tables
  • Handling Multi-Column Sequences
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting to Exclude the Maximum ID in the Self-Join
  • Mistake 2: Off-By-One Errors in Date Range Boundaries
  • Mistake 3: Using NOT IN with a Subquery That Can Return NULLs
  • Mistake 4: Running Gap Detection on Unindexed Columns
  • Mistake 5: Assuming Sequence Gaps Are Always Errors
  • Mistake 6: Not Accounting for Multiple Sequences in One Table
  • Summary & Next Steps
  • Writing SQL to Detect and Fill Sequence Gaps: Using Self-Joins and Subqueries to Find Missing IDs and Date Ranges

    Introduction

    You're auditing an order management system and someone asks you to pull all orders from last quarter. You write the query, skim the results, and something feels off — the order IDs jump from 10,423 to 10,431. Eight orders are missing. Were they deleted? Never created? Migrated incorrectly from a legacy system? You don't know yet, but you need to find out. That's the moment gap detection becomes critical.

    Sequence gaps show up everywhere in real data work. Auto-increment IDs that skip when transactions roll back. Date ranges in scheduling tables that leave certain days uncovered. Invoice numbers that should be contiguous but aren't. Employee ID sequences with holes from terminated contractors. Every one of these gaps might represent a data quality issue, a business process failure, or worse — deleted records that someone doesn't want you to find. SQL gives you powerful tools to surface them, and by the end of this lesson you'll be able to use both self-joins and subqueries to find gaps systematically, reconstruct what's missing, and fill them when appropriate.

    What you'll learn:

    • How to detect missing integer IDs in a sequence using self-joins
    • How to find gaps in date ranges using the "lead/lag" pattern with subqueries
    • How to generate a complete reference sequence and LEFT JOIN against it to expose gaps
    • How to use NOT EXISTS and NOT IN as alternative gap-detection strategies
    • How to write fill queries that INSERT or UPDATE to close gaps in sequence tables
    • Performance trade-offs between each approach at scale, and when each technique breaks down

    Prerequisites

    You should be comfortable with SQL JOINs — especially LEFT JOINs and self-joins — before working through this material. You'll also need a solid understanding of subqueries and their role in filtering, and ideally some exposure to advanced subquery and CTE patterns. We'll build on all three throughout the lesson.


    The Two Flavors of Sequence Gaps

    Before writing any SQL, you need to understand what you're actually looking for. There are two fundamentally different types of gaps, and they require different techniques.

    Integer sequence gaps are holes in a numeric series. Your orders table has IDs 1 through 10,000, but some are missing. You want a list of which IDs don't exist. The challenge here is that the "missing" values don't exist anywhere in your database — you're looking for absence.

    Date range gaps are intervals of time where no row covers a given period. You have a product_pricing table where each row has a valid_from and valid_to date, and you need to know if any day is left uncovered. Here the data exists, but the coverage has holes.

    These problems look similar on the surface but require different reasoning. Integer gaps are about comparing a sequence to itself. Date range gaps are about comparing intervals to each other. We'll tackle them separately.

    Key insight

    Gap detection is fundamentally a problem of finding what isn't there. That makes it structurally different from most SQL problems, which filter and transform rows that do exist. This is why LEFT JOINs, NOT EXISTS, and NOT IN — all tools for identifying absence — are central to gap detection.


    Finding Integer ID Gaps with a Self-Join

    The self-join approach to gap detection is elegant once you understand the logic. The idea: if you join a table to itself, pairing each row with the row that should come immediately after it, then any row where that "next" row is missing indicates a gap.

    Let's work with a concrete scenario. You manage an e-commerce platform and the orders table looks like this:

    CREATE TABLE orders (
        order_id    INT PRIMARY KEY,
        customer_id INT,
        order_date  DATE,
        status      VARCHAR(20)
    );
    

    You want to find all missing order_id values between 1 and the maximum ID. The self-join approach starts by joining orders to itself where the right side has an ID exactly one greater than the left side:

    SELECT
        o1.order_id AS gap_starts_after
    FROM orders o1
    LEFT JOIN orders o2
        ON o2.order_id = o1.order_id + 1
    WHERE o2.order_id IS NULL
      AND o1.order_id < (SELECT MAX(order_id) FROM orders)
    ORDER BY o1.order_id;
    

    Walk through the logic: for every row in o1, we try to find a matching row in o2 where o2.order_id = o1.order_id + 1. If that join produces a NULL on the right side, it means no row exists with that next ID — so a gap starts right after o1.order_id. The final condition excludes the maximum ID itself, because there's no "next row" to expect after the last one.

    This query tells you where gaps start, but it doesn't tell you how large they are or what the missing values actually are. Let's upgrade it to show you the gap range:

    SELECT
        o1.order_id + 1          AS gap_start,
        MIN(o2.order_id) - 1     AS gap_end,
        MIN(o2.order_id) - o1.order_id - 1 AS gap_size
    FROM orders o1
    JOIN orders o2
        ON o2.order_id > o1.order_id
    WHERE NOT EXISTS (
        SELECT 1 FROM orders o3
        WHERE o3.order_id = o1.order_id + 1
    )
    GROUP BY o1.order_id
    ORDER BY o1.order_id;
    

    This version uses a correlated subquery in the WHERE clause to confirm the gap exists, then groups by the starting point of each gap and finds the next actual ID (MIN(o2.order_id)) to determine where the gap ends. The result gives you something genuinely useful:

    gap_start | gap_end | gap_size
    ----------+---------+---------
    10424     | 10430   | 7
    15001     | 15001   | 1
    22100     | 22215   | 116
    

    Warning

    The self-join approach with a non-equijoin (o2.order_id > o1.order_id) can be very expensive on large tables. For every row in o1, it potentially scans many rows in o2. On a table with 10 million rows, this creates a query that may take minutes. Always check your execution plan before running this against production. We'll cover more scalable alternatives later.

    Understanding the Edge Case: Gap at the Beginning

    The self-join approach naturally finds gaps between existing values, but it won't catch a gap at the very start of your sequence. If your IDs are supposed to start at 1 but the first real order has ID 5, those four missing records won't appear.

    To handle this, you need to either add a sentinel row or handle it separately:

    -- Check if sequence starts at expected minimum
    SELECT
        CASE
            WHEN MIN(order_id) > 1
            THEN 'Gap at start: IDs 1 through ' || (MIN(order_id) - 1)::TEXT || ' are missing'
            ELSE 'Sequence starts correctly at 1'
        END AS start_gap_check
    FROM orders;
    

    The Reference Sequence Approach: Generate All Expected Values

    The self-join approach is clever but has a fundamental limitation — it only knows about the IDs that exist. For complete gap coverage, you need to compare your actual data against a complete list of what should be there. This is the reference sequence approach.

    Most databases have a way to generate a series of integers. Here's how to do it in the major dialects:

    PostgreSQL:

    SELECT generate_series(1, 10000) AS expected_id;
    

    MySQL (using a recursive CTE):

    WITH RECURSIVE seq AS (
        SELECT 1 AS n
        UNION ALL
        SELECT n + 1 FROM seq WHERE n < 10000
    )
    SELECT n AS expected_id FROM seq;
    

    SQL Server:

    WITH seq AS (
        SELECT 1 AS n
        UNION ALL
        SELECT n + 1 FROM seq WHERE n < 10000
    )
    SELECT n AS expected_id FROM seq
    OPTION (MAXRECURSION 10000);
    

    Once you have a reference sequence, the gap-finding query becomes a straightforward LEFT JOIN — a technique you've likely used before, just applied to absence detection. If you want a refresher on how the different join types affect which rows appear in results, see Choosing the Right JOIN Type.

    WITH expected AS (
        SELECT generate_series(
            (SELECT MIN(order_id) FROM orders),
            (SELECT MAX(order_id) FROM orders)
        ) AS expected_id
    )
    SELECT e.expected_id AS missing_order_id
    FROM expected e
    LEFT JOIN orders o ON o.order_id = e.expected_id
    WHERE o.order_id IS NULL
    ORDER BY e.expected_id;
    

    This approach is clean, readable, and complete. It will catch gaps at the start, end, and middle of your range. The downside is that generating a large series inline can be memory-intensive, and recursive CTEs in MySQL and SQL Server have default recursion limits you'll need to override for long sequences.

    Tip

    Rather than hard-coding the range bounds, parameterize them dynamically using MIN() and MAX() subqueries as shown above. This makes the query reusable across different tables without modification.


    Using NOT EXISTS for Gap Detection

    NOT EXISTS gives you another angle on the same problem. Instead of generating a complete sequence and finding what's missing via LEFT JOIN, you check each potential ID against the table directly. This is particularly effective when you want to frame the logic as: "for each number in this range, does a corresponding row exist?"

    Mastering SQL EXISTS and NOT EXISTS covers the mechanics in depth, but the key insight here is that NOT EXISTS short-circuits — as soon as it finds a match, it stops looking. This can make it faster than LEFT JOIN in certain situations.

    WITH expected AS (
        SELECT generate_series(
            (SELECT MIN(order_id) FROM orders),
            (SELECT MAX(order_id) FROM orders)
        ) AS expected_id
    )
    SELECT expected_id AS missing_order_id
    FROM expected
    WHERE NOT EXISTS (
        SELECT 1 FROM orders o
        WHERE o.order_id = expected.expected_id
    )
    ORDER BY expected_id;
    

    The result is identical to the LEFT JOIN version. The choice between them is mostly stylistic and performance-driven. On PostgreSQL with a B-tree index on order_id, both will use index seeks for the inner lookup, making performance roughly equivalent. On databases without good index usage for correlated subqueries, LEFT JOIN often performs better.

    Note

    NOT IN is a tempting alternative to NOT EXISTS, but it has a dangerous behavior with NULLs. If the subquery returns any NULL value, NOT IN returns no rows at all — silently, with no error. Always prefer NOT EXISTS when checking for absence. If you're unfamiliar with this NULL behavior, review NULL Handling in SQL before using NOT IN in production queries.


    Detecting Date Range Gaps: A More Complex Problem

    Integer gaps are a warm-up. Date range gaps are where things get genuinely interesting — and genuinely tricky.

    Imagine you're working with a product_pricing table for a subscription business:

    CREATE TABLE product_pricing (
        pricing_id   INT PRIMARY KEY,
        product_id   INT,
        price        DECIMAL(10, 2),
        valid_from   DATE NOT NULL,
        valid_to     DATE NOT NULL
    );
    

    Each row says "this product costs $X between these dates." For billing to work correctly, every single day must be covered by exactly one pricing row. Your task is to find any days that fall between price records — days where the price is undefined.

    This is harder than integer gap detection for two reasons:

    1. The gaps occur between rows, not in a sequence of the rows themselves
    2. You need to compare each row to the next row in time, not to a fixed sequence

    The Lead-Row Pattern with Subqueries

    The classic approach uses a subquery to find, for each pricing row, what the next valid_from date is for the same product. If there's a gap between valid_to of row N and valid_from of row N+1, that gap is uncovered:

    SELECT
        p1.product_id,
        p1.valid_to + INTERVAL '1 day'      AS gap_start,
        next_period.next_from - INTERVAL '1 day' AS gap_end,
        next_period.next_from - p1.valid_to - 1  AS gap_days
    FROM product_pricing p1
    CROSS JOIN LATERAL (
        SELECT MIN(p2.valid_from) AS next_from
        FROM product_pricing p2
        WHERE p2.product_id = p1.product_id
          AND p2.valid_from > p1.valid_to
    ) AS next_period
    WHERE next_period.next_from IS NOT NULL
      AND next_period.next_from > p1.valid_to + INTERVAL '1 day'
    ORDER BY p1.product_id, p1.valid_to;
    

    The LATERAL subquery (supported in PostgreSQL and modern SQL Server via CROSS APPLY) evaluates once per row of p1, finding the soonest valid_from date in the same product's history that comes after the current row's valid_to. If that next start date is more than one day after the current end date, there's a gap.

    If your database doesn't support LATERAL, you can rewrite this as a correlated subquery in the SELECT clause:

    SELECT
        p1.product_id,
        p1.valid_to + INTERVAL '1 day'      AS gap_start,
        (
            SELECT MIN(p2.valid_from)
            FROM product_pricing p2
            WHERE p2.product_id = p1.product_id
              AND p2.valid_from > p1.valid_to
        ) - INTERVAL '1 day'                AS gap_end
    FROM product_pricing p1
    WHERE (
        SELECT MIN(p2.valid_from)
        FROM product_pricing p2
        WHERE p2.product_id = p1.product_id
          AND p2.valid_from > p1.valid_to
    ) > p1.valid_to + INTERVAL '1 day'
    ORDER BY p1.product_id, p1.valid_to;
    

    This works but executes the correlated subquery twice — once in SELECT and once in WHERE. You can avoid duplication by wrapping it in a CTE or derived table:

    WITH pricing_with_next AS (
        SELECT
            p1.product_id,
            p1.valid_from,
            p1.valid_to,
            (
                SELECT MIN(p2.valid_from)
                FROM product_pricing p2
                WHERE p2.product_id = p1.product_id
                  AND p2.valid_from > p1.valid_to
            ) AS next_valid_from
        FROM product_pricing p1
    )
    SELECT
        product_id,
        valid_to + INTERVAL '1 day'      AS gap_start,
        next_valid_from - INTERVAL '1 day' AS gap_end,
        (next_valid_from - valid_to - 1)   AS gap_days
    FROM pricing_with_next
    WHERE next_valid_from IS NOT NULL
      AND next_valid_from > valid_to + INTERVAL '1 day'
    ORDER BY product_id, valid_to;
    

    This version is cleaner and the CTE will typically be computed once, making it more efficient than the double-subquery version.

    Key insight

    The date arithmetic here is database-specific. PostgreSQL lets you subtract dates directly to get an integer number of days. SQL Server uses DATEDIFF. MySQL doesn't support INTERVAL syntax the same way. Always test date gap queries in a sandbox before running them in production — off-by-one errors in date boundary logic are silent and insidious. The Master SQL String and Date Functions lesson covers cross-dialect date arithmetic in detail.

    Handling Overlaps: The Gap Detection Problem in Reverse

    While you're looking for gaps, you should also check for overlaps — periods where two pricing rows cover the same day. An overlap is as dangerous as a gap for billing logic.

    SELECT
        p1.pricing_id      AS row1_id,
        p2.pricing_id      AS row2_id,
        p1.product_id,
        GREATEST(p1.valid_from, p2.valid_from) AS overlap_start,
        LEAST(p1.valid_to, p2.valid_to)        AS overlap_end
    FROM product_pricing p1
    JOIN product_pricing p2
        ON p1.product_id = p2.product_id
       AND p1.pricing_id < p2.pricing_id  -- avoid self-match and duplicate pairs
       AND p1.valid_from <= p2.valid_to
       AND p1.valid_to   >= p2.valid_from
    ORDER BY p1.product_id, overlap_start;
    

    This is a self-join that checks whether two date ranges overlap using the standard overlap condition: range A overlaps range B if A starts before B ends AND A ends after B starts. The condition p1.pricing_id < p2.pricing_id ensures you don't match a row to itself and you don't see the same overlap reported twice.


    Generating the Missing Rows: Going Beyond Detection

    Finding gaps is step one. Step two is often deciding what to do about them. In some cases — like missing audit log entries — a gap is just a finding you report. In others — like a pricing table with uncovered dates — you need to fill the gap with actual data.

    Warning

    Filling gaps in production data should always involve a business decision, not just a technical one. An INSERT that fills a pricing gap with a placeholder price could affect billing calculations if other code starts using that row before you're ready. Always fill gaps in a staging environment first, and always use a transaction that you can roll back.

    Inserting Placeholder Records for Missing IDs

    Suppose your business rule is that missing order IDs should be documented with a "void" record so the sequence is complete for audit purposes. Here's how you'd insert those records:

    INSERT INTO orders (order_id, customer_id, order_date, status)
    WITH expected AS (
        SELECT generate_series(
            (SELECT MIN(order_id) FROM orders),
            (SELECT MAX(order_id) FROM orders)
        ) AS expected_id
    )
    SELECT
        e.expected_id,
        NULL   AS customer_id,
        NULL   AS order_date,
        'VOID' AS status
    FROM expected e
    LEFT JOIN orders o ON o.order_id = e.expected_id
    WHERE o.order_id IS NULL;
    

    This uses the same gap-detection logic inside an INSERT statement. The CTE finds all expected IDs, the LEFT JOIN filters to only those that don't exist, and the outer INSERT creates void records for each gap.

    If you need a refresher on safe data modification patterns, INSERT, UPDATE, DELETE: Master SQL Data Modification Safely covers how to use transactions and staging tables to make DML reversible.

    Filling Date Range Gaps with Derived Records

    The date range case is more nuanced. When a pricing gap exists, you can't just insert a NULL price — you need a business rule. Common options: carry forward the previous price, use a default price, or flag the gap for manual review and insert a sentinel record.

    Here's an approach that carries forward the last known price into the gap:

    INSERT INTO product_pricing (product_id, price, valid_from, valid_to)
    WITH pricing_with_next AS (
        SELECT
            p1.product_id,
            p1.price,
            p1.valid_from,
            p1.valid_to,
            (
                SELECT MIN(p2.valid_from)
                FROM product_pricing p2
                WHERE p2.product_id = p1.product_id
                  AND p2.valid_from > p1.valid_to
            ) AS next_valid_from
        FROM product_pricing p1
    ),
    gaps AS (
        SELECT
            product_id,
            price,                               -- carry forward the previous price
            valid_to + INTERVAL '1 day'          AS gap_valid_from,
            next_valid_from - INTERVAL '1 day'   AS gap_valid_to
        FROM pricing_with_next
        WHERE next_valid_from IS NOT NULL
          AND next_valid_from > valid_to + INTERVAL '1 day'
    )
    SELECT product_id, price, gap_valid_from, gap_valid_to
    FROM gaps;
    

    This query layers two CTEs: pricing_with_next finds each row and the date when the next pricing period starts, and gaps extracts the uncovered interval and inherits the previous row's price. The INSERT then materializes those gap records.

    Tip

    Always run the SELECT portion of a gap-fill INSERT before actually executing the INSERT. Inspect the proposed new rows carefully. Does the valid_to of the gap row ever exceed the valid_from of the next legitimate row? If your date arithmetic is off by a day, you'll create overlaps while trying to fix gaps. Double-check the boundary values in the output.


    Advanced Pattern: Using Window Functions for Gap Detection

    If your database supports window functions — and any modern database does — you have a faster and more readable approach available. LAG() and LEAD() let you look at adjacent row values without a self-join.

    This approach is covered extensively in CTE and window function lessons, but it's worth including here because it's the production-grade solution for large tables:

    WITH ordered_pricing AS (
        SELECT
            product_id,
            price,
            valid_from,
            valid_to,
            LEAD(valid_from) OVER (
                PARTITION BY product_id
                ORDER BY valid_from
            ) AS next_valid_from
        FROM product_pricing
    )
    SELECT
        product_id,
        valid_to + INTERVAL '1 day'            AS gap_start,
        next_valid_from - INTERVAL '1 day'     AS gap_end,
        (next_valid_from - valid_to - 1)       AS gap_days
    FROM ordered_pricing
    WHERE next_valid_from IS NOT NULL
      AND next_valid_from > valid_to + INTERVAL '1 day'
    ORDER BY product_id, valid_to;
    

    LEAD(valid_from) OVER (PARTITION BY product_id ORDER BY valid_from) looks ahead to the next row's valid_from date, within each product's history, ordered by date. The CTE computes this once; the outer query then filters to only the rows where a gap exists.

    Compare this to the correlated subquery version. The window function approach:

    • Scans product_pricing once (or twice with a materialized CTE)
    • Does not execute a subquery per row
    • Uses the same index that orders the PARTITION BY + ORDER BY columns

    The correlated subquery version:

    • Executes a subquery once per row of product_pricing
    • Can use an index on (product_id, valid_from) to accelerate each inner lookup
    • Is more readable for those unfamiliar with window functions

    At small scale (under ~100K rows), both are fine. At millions of rows, window functions win. Always check the execution plan with EXPLAIN ANALYZE in PostgreSQL or SET STATISTICS TIME ON in SQL Server.

    Key insight

    Window functions don't change what you're doing — finding the next row in a sequence — they change how the database executes it. A correlated subquery says "for each row, go find the answer separately." A window function says "compute this for all rows in one pass." That difference can be a 10x or 100x performance difference on large tables.


    Performance Considerations and Index Strategy

    Gap detection queries have a reputation for being slow, but that's usually because they're written without considering the access patterns they create. Let's think through the indexing requirements for each approach.

    Indexing for Integer Gap Detection

    For the self-join approach on orders:

    -- The join condition is:  o2.order_id = o1.order_id + 1
    -- The subquery condition is: o3.order_id = o1.order_id + 1
    -- Both need efficient point lookups on order_id
    -- A PRIMARY KEY on order_id covers this entirely
    

    If order_id is already your primary key (and it almost certainly is), the self-join approach uses index seeks for every row comparison. This is actually efficient for small to medium gaps detection.

    For the reference sequence approach:

    -- The LEFT JOIN condition is: o.order_id = e.expected_id
    -- Same story: primary key index covers it
    -- The generate_series result is in memory, not indexed
    -- Query becomes: for each generated ID, seek into orders
    -- Performance: O(n log m) where n = sequence length, m = orders count
    

    For tables with millions of rows and sequences in the tens of millions, this can still be slow because n (the sequence length) drives the number of seeks.

    Indexing for Date Range Gap Detection

    The product_pricing query joins on product_id and filters/sorts by valid_from:

    -- Create a composite index on (product_id, valid_from)
    CREATE INDEX idx_pricing_product_dates
        ON product_pricing (product_id, valid_from, valid_to);
    

    Including valid_to as a third column makes this a covering index for the correlated subquery version, so no heap access is needed. The index supports:

    • WHERE p2.product_id = p1.product_id (equality on first column)
    • AND p2.valid_from > p1.valid_to (range scan on second column)
    • Reading valid_from and valid_to from the index directly (covering)

    Warning

    Adding a composite index on (product_id, valid_from) helps the gap detection query enormously, but it also adds overhead to INSERT and UPDATE operations on product_pricing. If this table receives thousands of writes per second, benchmark the impact before adding the index. For more on balancing read and write performance, see SQL Indexes Explained: How They Work and When to Create Them.

    Partitioning and Batch Strategies for Very Large Tables

    If you're detecting gaps in a table with tens of millions of rows, running a single gap-detection query against the whole table is usually the wrong approach. Instead:

    1. Partition the search space — run gap detection per date range, per partition, or per logical segment
    2. Materialize intermediate results — compute the "ordered sequence with next values" once into a temp table, then query the temp table for gaps
    3. Use approximate gap detection first — check for large gaps quickly using MAX(id) - MIN(id) - COUNT(*) + 1 to see if gaps exist at all before running a full scan
    -- Quick check: are there ANY gaps in this table?
    SELECT
        MAX(order_id) - MIN(order_id) + 1 AS expected_count,
        COUNT(*) AS actual_count,
        (MAX(order_id) - MIN(order_id) + 1) - COUNT(*) AS gap_count
    FROM orders;
    

    If gap_count is 0, you're done. If it's nonzero, you know the scale of the problem and can run the full detection query with appropriate expectations.

    This pattern also helps you prioritize — a gap_count of 3 might be investigated manually; a gap_count of 50,000 requires an automated pipeline.


    Handling Multi-Column Sequences

    Real-world sequences are often composite. Your monthly_report table might have gaps in a combination of (year, month) pairs. Your employee_attendance table might have gaps in (employee_id, date). Single-column techniques don't translate directly.

    Here's a date-based composite gap check for an attendance table where every employee should have a record for every working day:

    CREATE TABLE employee_attendance (
        employee_id INT,
        work_date   DATE,
        hours_worked DECIMAL(4,2),
        PRIMARY KEY (employee_id, work_date)
    );
    

    To find missing attendance records, you need a cross product of employees × expected dates:

    WITH date_spine AS (
        SELECT generate_series(
            '2024-01-01'::DATE,
            '2024-12-31'::DATE,
            '1 day'::INTERVAL
        )::DATE AS expected_date
    ),
    working_days AS (
        -- Filter to weekdays only
        SELECT expected_date
        FROM date_spine
        WHERE EXTRACT(DOW FROM expected_date) NOT IN (0, 6)  -- 0=Sunday, 6=Saturday
    ),
    all_employees AS (
        SELECT DISTINCT employee_id FROM employee_attendance
    ),
    expected_attendance AS (
        SELECT e.employee_id, w.expected_date
        FROM all_employees e
        CROSS JOIN working_days w
    )
    SELECT
        ea.employee_id,
        ea.expected_date AS missing_date
    FROM expected_attendance ea
    LEFT JOIN employee_attendance att
        ON att.employee_id = ea.employee_id
       AND att.work_date = ea.expected_date
    WHERE att.work_date IS NULL
    ORDER BY ea.employee_id, ea.expected_date;
    

    This generates the full expected set using a CROSS JOIN between employees and working days, then LEFT JOINs to actual data to surface what's missing. For 500 employees and 250 working days, the expected set has 125,000 rows — manageable in memory. For 50,000 employees, you'd want to run this per department or date range batch.

    Note

    The generate_series function with an INTERVAL argument returns TIMESTAMP, not DATE, in PostgreSQL. The ::DATE cast at the end of the series generation is required or date comparisons will fail silently with type mismatch coercions. This is a common source of bugs in date spine queries.


    Hands-On Exercise

    Work through this scenario to practice the full gap detection workflow. Use PostgreSQL syntax — adjust as needed for your database.

    Scenario: You're a data engineer at a logistics company. The shipment_tracking table records status updates for packages, and you've been told there may be missing entries. The table schema:

    CREATE TABLE shipment_tracking (
        tracking_event_id   INT PRIMARY KEY,
        shipment_id         INT,
        event_timestamp     TIMESTAMP,
        event_status        VARCHAR(50)
    );
    
    -- Sample data with intentional gaps
    INSERT INTO shipment_tracking VALUES
    (1,  101, '2024-01-15 09:00:00', 'PICKED_UP'),
    (2,  101, '2024-01-15 14:30:00', 'IN_TRANSIT'),
    (4,  102, '2024-01-16 08:00:00', 'PICKED_UP'),  -- note: ID 3 is missing
    (5,  102, '2024-01-16 17:00:00', 'IN_TRANSIT'),
    (6,  103, '2024-01-17 11:00:00', 'PICKED_UP'),
    (9,  103, '2024-01-17 23:00:00', 'DELIVERED'),  -- note: IDs 7 and 8 are missing
    (10, 104, '2024-01-18 09:30:00', 'PICKED_UP');
    

    Exercise tasks:

    Task 1: Write a self-join query that identifies which tracking_event_id values have a gap immediately after them. Your output should include the ID where the gap starts and the ID where the next real record appears.

    Task 2: Rewrite Task 1 using the reference sequence approach with generate_series. List every individual missing ID.

    Task 3: Compare the outputs of Task 1 and Task 2. Can you think of a scenario where the self-join approach would miss a gap that the reference sequence approach catches? (Hint: think about boundaries.)

    Task 4: Write an INSERT statement that creates void placeholder records for all the missing IDs. Use NULL for shipment_id and event_timestamp, and 'VOID' for event_status. Wrap it in a transaction and verify the results before committing.

    Task 5 (Challenge): The operations team also wants to know if any shipment_id has a gap of more than 2 hours between consecutive tracking events. Write a query that finds those shipment_id values and shows the start and end of each long gap.

    Expected output for Task 2:

    missing_tracking_event_id
    --------------------------
    3
    7
    8
    

    Common Mistakes & Troubleshooting

    Mistake 1: Forgetting to Exclude the Maximum ID in the Self-Join

    When you write the basic self-join (LEFT JOIN ON o2.order_id = o1.order_id + 1), every row where the next ID doesn't exist gets flagged — including the maximum ID, because there's legitimately no row after the last one.

    Fix: Add AND o1.order_id < (SELECT MAX(order_id) FROM orders) to exclude the final row.

    Mistake 2: Off-By-One Errors in Date Range Boundaries

    If your pricing table uses inclusive valid_to dates (a row valid through December 31 has valid_to = '2024-12-31'), then a new row starting January 1 is not a gap — it's adjacent. But if you write:

    WHERE next_valid_from > valid_to  -- WRONG: this flags adjacent ranges as gaps
    

    You should write:

    WHERE next_valid_from > valid_to + INTERVAL '1 day'  -- CORRECT: adjacent is not a gap
    

    Warning

    This boundary logic varies depending on whether your date ranges are inclusive/exclusive on each end. Document your convention and enforce it with a CHECK constraint if possible. Mixed conventions in the same table are a source of very difficult bugs.

    Mistake 3: Using NOT IN with a Subquery That Can Return NULLs

    -- DANGEROUS: if generate_series returns any NULLs, this returns no rows
    SELECT expected_id
    FROM generate_series(1, 1000) AS expected_id
    WHERE expected_id NOT IN (SELECT order_id FROM orders);
    

    generate_series won't return NULLs in this case, but if you replace it with a subquery against a real table where order_id might be NULL, NOT IN silently returns nothing. Prefer NOT EXISTS for robustness.

    Mistake 4: Running Gap Detection on Unindexed Columns

    Gap detection on a non-indexed column will cause a full table scan for every row in the outer query. This can turn a sub-second query on a small table into a multi-minute query on a large one.

    Diagnosis: Run EXPLAIN before running the query. Look for "Seq Scan" on the inner table — that's the warning sign. If you see it, add an index or reconsider your approach.

    Mistake 5: Assuming Sequence Gaps Are Always Errors

    Not every gap is a data quality problem. Auto-increment sequences skip IDs when transactions roll back — this is expected behavior, not a bug. Deleted records legitimately leave holes. Before filling gaps, always understand why they exist.

    Tip

    When you find gaps, add a step to your investigation: check whether the database has a sequence object (PostgreSQL SEQUENCE, SQL Server IDENTITY) whose current value explains the skip. A sequence that was reset, incremented manually, or used for multiple purposes can explain many apparent gaps without any data loss.

    Mistake 6: Not Accounting for Multiple Sequences in One Table

    If your orders table has IDs from multiple sources (domestic orders, imported from a legacy system, bulk-loaded from a partner), there's no single expected sequence. A gap between ID 10000 and ID 20001 might simply be where the domestic sequence ends and the partner-sourced sequence begins.

    Fix: Always understand the business rules governing your ID column before interpreting gaps. If possible, partition your gap analysis by source system using a separate source_system column.


    Summary & Next Steps

    Gap detection is one of those SQL skills that looks simple until you actually need it in production — and then the edge cases multiply fast. Let's recap what you've learned.

    You now know three distinct approaches to integer gap detection:

    • Self-join — elegant and readable, works by pairing each row with its expected successor; best for smaller tables and quick audits
    • Reference sequence + LEFT JOIN — complete and reliable, generates all expected values and finds what's absent; best when you need every missing value listed
    • NOT EXISTS — semantically precise, avoids the NULL trap of NOT IN; best when you want to frame the logic as "does this expected value exist?"

    For date range gaps, you've learned the lead-row pattern using correlated subqueries and CTEs, as well as the window function alternative that scales better on large tables. You also know how to detect the inverse problem — overlapping ranges — using a self-join with the standard overlap condition.

    You've seen how to translate gap detection into gap filling with INSERT statements, with appropriate warnings about the business decisions that need to precede any data modification.

    Where to go from here:

    If this lesson sparked an interest in the broader category of comparing rows to each other — not just for gap detection but for change detection, duplicate finding, and time-series analysis — dive into Writing SQL Self-Joins: Query the Same Table Twice to Compare Rows and Find Relationships. The techniques overlap significantly.

    For building complete analytical queries that chain together the kinds of CTEs and subqueries you've seen here, Writing Multi-Step Analytical Queries walks through structuring complex SQL from requirements to production-ready code.

    And if you want to understand why some of these queries are fast and others are slow at a deeper level, SQL Indexes Explained will give you the mental model you need to write gap detection queries that stay fast as your tables grow.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    SQL Fundamentals

    Previous

    Writing SQL to Calculate Period-Over-Period Change: Date Filtering, GROUP BY, and Derived Table Comparisons in One Query

    Related Insights

    SQLPractitioner

    Writing SQL to Simulate LAG and LEAD Across Partition Boundaries: Handling Edge Cases Where Window Functions Return NULL

    22 min
    SQLFoundation

    Reading and Interpreting SQL Query Execution Plans: A Beginner's Guide to EXPLAIN Output and Scan Types

    17 min
    SQLPractitioner

    Writing SQL to Calculate Period-Over-Period Change: Date Filtering, GROUP BY, and Derived Table Comparisons in One Query

    20 min

    On this page

    • Introduction
    • Prerequisites
    • The Two Flavors of Sequence Gaps
    • Finding Integer ID Gaps with a Self-Join
    • Understanding the Edge Case: Gap at the Beginning
    • The Reference Sequence Approach: Generate All Expected Values
    • Using NOT EXISTS for Gap Detection
    • Detecting Date Range Gaps: A More Complex Problem
    • The Lead-Row Pattern with Subqueries
    • Handling Overlaps: The Gap Detection Problem in Reverse
    • Generating the Missing Rows: Going Beyond Detection
    • Inserting Placeholder Records for Missing IDs
    • Filling Date Range Gaps with Derived Records
    • Advanced Pattern: Using Window Functions for Gap Detection
    • Performance Considerations and Index Strategy
    • Indexing for Integer Gap Detection
    • Indexing for Date Range Gap Detection
    • Partitioning and Batch Strategies for Very Large Tables
    • Handling Multi-Column Sequences
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Forgetting to Exclude the Maximum ID in the Self-Join
    • Mistake 2: Off-By-One Errors in Date Range Boundaries
    • Mistake 3: Using NOT IN with a Subquery That Can Return NULLs
    • Mistake 4: Running Gap Detection on Unindexed Columns
    • Mistake 5: Assuming Sequence Gaps Are Always Errors
    • Mistake 6: Not Accounting for Multiple Sequences in One Table
    • Summary & Next Steps