Gaps and islands

Subtract a row's position inside its group from its position overall: within one unbroken run, that difference never moves.

The idea

Walk a calendar and number every day: 1, 2, 3, 4… Now number the days a second time, but keep a separate count for the active days and for the missing ones. Inside an unbroken run both counters step forward together, so the difference between them stands perfectly still.

The moment the run breaks, one counter carries on while the other pauses, and the difference jumps. That frozen difference is a group key. Group by it (together with which kind of run you are in) and each group is one island; min, max and count hand you its start, end and length. Whatever falls between two islands is a gap, and a "streak" is just the longest island.

Watch the difference freeze

the strip scrolls sideways on narrow screens

14 days

Click any day to switch it on or off (or focus it with tab and press enter). Knock out a day in the middle of an island and watch the run split, the colours part, and every later difference shift by one.

islandstartendlengthdiff

How it works

Two window functions do all the work. ROW_NUMBER() OVER (ORDER BY day) gives a row its position in the whole ordered set. ROW_NUMBER() OVER (PARTITION BY key ORDER BY day) gives its position inside its own group. Both advance by one per row while a run is unbroken, so their difference is constant; when a run is interrupted, only the first counter advances during the interruption, so the difference rises by exactly the size of the interruption.

The difference is meaningless on its own: it is only a label. Group by the partition key plus the difference and every group is a maximal run.

-- one row per calendar day, is_active tells you which kind of run you are in
WITH stamped AS (
  SELECT
    day,
    is_active,
    ROW_NUMBER() OVER (ORDER BY day)                            AS rn_all,
    ROW_NUMBER() OVER (PARTITION BY is_active ORDER BY day)     AS rn_key
  FROM calendar
)
SELECT
  is_active,
  MIN(day)  AS start_day,
  MAX(day)  AS end_day,
  COUNT(*)  AS length
FROM stamped
GROUP BY is_active, rn_all - rn_key   -- the key must include is_active
ORDER BY start_day;

When you only have the rows that exist (logins, say) and "consecutive" means consecutive calendar dates, the date itself plays the part of rn_all:

SELECT user_id,
       MIN(d) AS streak_start,
       MAX(d) AS streak_end,
       COUNT(*) AS streak_len
FROM (
  SELECT user_id, d,
         d - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d) * INTERVAL '1 day' AS grp
  FROM (SELECT DISTINCT user_id, login_at::date AS d FROM logins) x
) y
GROUP BY user_id, grp
ORDER BY user_id, streak_start;

Cost

steptimespace
order rows by (key, day)O(n log n)O(n)
two row_number passesO(n)O(1) extra
group by (key, diff)O(n)O(g) groups
totalO(n log n)O(n)

The sort dominates, and it disappears to O(n) if an index already supplies (key, day) order. The trade-off: you touch every row and materialise the whole sorted set, but you get every island in a single declarative pass. No cursor, no self-join, no recursive CTE, and the planner can parallelise it.

Watch out for

Worked example

"What is each user's longest login streak?" User 7 logged in on 10, 11, 12, 15, 16, 17, 18, 20, 21 and 22 March. Number those ten dates 1 through 10 and subtract the row number as days: 10−1, 11−2, 12−3 all land on Mar 09; 15−4 … 18−7 all land on Mar 11; 20−8 … 22−10 all land on Mar 12.

Three anchors, three streaks. GROUP BY user_id, grp with MIN, MAX, COUNT returns 10 to 12 (3 days), 15 to 18 (4 days) and 20 to 22 (3 days); one more MAX(streak_len) per user answers the question. The gaps fall out of the same rows: 13 to 14 and 19. Notice that if the 16th appeared twice in the log, the second copy would shift every later row number and report the four-day streak as two shorter ones, which is why the DISTINCT is not optional.

Check yourself

A user's login dates are 3 May, 4 May, 4 May (they logged in twice) and 5 May. You group by date − row_number. What happens?

In the strip above, a missing run and an active island can both carry difference 3. If you wrote GROUP BY diff alone, what would happen?