Streaks and gaps

Group consecutive rows into streaks, and find the gaps between them, with one subtraction trick.

Advanced11 min readLesson 35 of 38
In this lesson 8 sections
  1. The data
  2. Step 1: a number that counts up by one
  3. Step 2: the trick
  4. Step 3: group by the key
  5. The gaps: what’s missing between streaks
  6. Common mistakes
  7. Dialect notes
  8. Snowflake notes
Jump to practice

“What’s our longest run of months with a win?” “Which days was the system down, in unbroken stretches?” “How many consecutive days has this user logged in?” These are gaps and islands problems. Islands are runs of consecutive values. Gaps are the holes between them. They’re a favorite interview question, because the standard answer is short but not obvious.

The data

Months with at least one won deal:

2025-03, 2025-05, 2025-06, 2025-08, 2025-09, 2025-11, 2025-12, 2026-01, 2026-02, 2026-03, 2026-04, 2026-06, 2026-09, 2026-10

By eye, the longest streak runs from November 2025 to April 2026: six months in a row, across a year boundary. The goal is to get SQL to find every streak like that.

Step 1: a number that counts up by one

Consecutive months need to be consecutive numbers. Month numbers alone restart every January, so December (12) and January (1) wouldn’t look consecutive. Multiply the year by 12 and add the month instead:

SELECT DISTINCT
       strftime('%Y-%m', closed_at) AS month,
       CAST(strftime('%Y', closed_at) AS INTEGER) * 12
         + CAST(strftime('%m', closed_at) AS INTEGER) AS month_num
FROM opportunities
WHERE is_won = 1;

December 2025 becomes 24312 and January 2026 becomes 24313, one apart, as they should be. DISTINCT gives each month once, however many deals it had.

Step 2: the trick

Number the rows in order with ROW_NUMBER (lesson 30), then subtract that from the month number:

month month_num row_number month_num − row_number
2025-11 24311 6 24305
2025-12 24312 7 24305
2026-01 24313 8 24305
2026-02 24314 9 24305
2026-03 24315 10 24305
2026-04 24316 11 24305
2026-06 24318 12 24306

Inside a streak, both columns go up by 1 each row, so their difference stays the same. When a month is skipped, the month number jumps by 2 but the row number only by 1, so the difference changes. Every streak gets its own constant key. The key’s actual value means nothing. It’s only used to group.

Step 3: group by the key

WITH win_months AS (
    SELECT DISTINCT
           strftime('%Y-%m', closed_at) AS month,
           CAST(strftime('%Y', closed_at) AS INTEGER) * 12
             + CAST(strftime('%m', closed_at) AS INTEGER) AS month_num
    FROM opportunities
    WHERE is_won = 1
),
keyed AS (
    SELECT month,
           month_num - ROW_NUMBER() OVER (ORDER BY month_num) AS streak_key
    FROM win_months
)
SELECT MIN(month) AS first_month,
       MAX(month) AS last_month,
       COUNT(*)   AS months
FROM keyed
GROUP BY streak_key
ORDER BY first_month;
first_month last_month months
2025-03 2025-03 1
2025-05 2025-06 2
2025-08 2025-09 2
2025-11 2026-04 6
2026-06 2026-06 1
2026-09 2026-10 2

Six streaks, the longest six months. The same three steps work for days, weeks or any sequence: get a number that goes up by exactly one per step, subtract ROW_NUMBER, then group.

The gaps: what’s missing between streaks

To find the gaps instead, compare each month with the next one using LEAD (lesson 31):

WITH win_months AS (
    SELECT DISTINCT
           strftime('%Y-%m', closed_at) AS month,
           CAST(strftime('%Y', closed_at) AS INTEGER) * 12
             + CAST(strftime('%m', closed_at) AS INTEGER) AS month_num
    FROM opportunities
    WHERE is_won = 1
)
SELECT month,
       LEAD(month) OVER (ORDER BY month_num)                 AS next_win,
       LEAD(month_num) OVER (ORDER BY month_num) - month_num - 1 AS empty_months
FROM win_months;

Any row with empty_months above 0 sits right before a gap. June 2026 shows 2: no wins in July or August.

Common mistakes

Mistake Fix
Using the month number alone (1 to 12) December and January won’t connect. Count across years.
Forgetting DISTINCT Two deals in one month become two rows, and the streak breaks
RANK instead of ROW_NUMBER Ties break the counting. Use ROW_NUMBER on unique values.
Reading meaning into the key It’s only a label for grouping

Dialect notes

The trick is the same everywhere. Only the month number changes: YEAR(d) * 12 + MONTH(d) in SQL Server and MySQL, and EXTRACT(YEAR FROM d) * 12 + EXTRACT(MONTH FROM d) in PostgreSQL. For streaks of days, PostgreSQL can subtract an integer from a date directly: d - ROW_NUMBER() OVER (ORDER BY d)::int gives a constant date for each streak.

Snowflake notes

In Snowflake, the streak key for days is DATEADD('day', -ROW_NUMBER() OVER (ORDER BY d), d), and for months, DATEADD('month', -ROW_NUMBER() OVER (ORDER BY m), m), which avoids the year-times-12 step. CONDITIONAL_TRUE_EVENT and CONDITIONAL_CHANGE_EVENT are Snowflake-only window functions that number islands directly, starting a new number whenever a condition is true or a value changes.

More in the field guide: Gaps & islands.

Try it

Find every streak of back-to-back months that had at least one won deal, across both years. Return the first month, the last month and the streak’s length in months, oldest first. Your result should match the table above.

Write it in the sandbox below, then press Check answer.

SQL sandbox

Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.

Back to the top of the lesson