CoreTrail

Consecutive days & streaks

Group runs using deduplicated dates and row numbers.

CorePostgreSQL3 min read
On this page

Question: Which users were active on at least three consecutive calendar days?

Assume sf_events(user_id, record_date), where record_date is a date or a timestamp interpreted in the intended reporting timezone.

WITH days AS (
    SELECT DISTINCT user_id, record_date::date AS activity_date
    FROM sf_events
    WHERE record_date IS NOT NULL
), numbered AS (
    SELECT user_id, activity_date,
           ROW_NUMBER() OVER (
               PARTITION BY user_id ORDER BY activity_date
           )::int AS rn
    FROM days
), grouped AS (
    SELECT user_id, activity_date, activity_date - rn AS streak_key
    FROM numbered
), runs AS (
    SELECT user_id, streak_key,
           MIN(activity_date) AS streak_start,
           MAX(activity_date) AS streak_end,
           COUNT(*) AS streak_length
    FROM grouped
    GROUP BY user_id, streak_key
)
SELECT DISTINCT user_id
FROM runs
WHERE streak_length >= 3;

Why subtracting ROW_NUMBER works

For one user’s deduplicated dates:

activity_date rn activity_date minus rn days
2026-01-10 1 2026-01-09
2026-01-11 2 2026-01-09
2026-01-12 3 2026-01-09
2026-01-15 4 2026-01-11
2026-01-16 5 2026-01-11

Inside a streak, both the date and row number increase by one, so their difference stays constant. A gap changes that difference.

The final DISTINCT user_id matters: one user could have several qualifying streaks. To show the streaks themselves, select the start, end, and length from runs instead.

Essential: Deduplicate dates before assigning row numbers. Several events on one day must not consume several positions in a day-level streak. This technique assumes a fixed one-day step; business-day streaks require a business calendar.

Alternative for exactly the threshold of three

WITH days AS (
    SELECT DISTINCT user_id, record_date::date AS activity_date
    FROM sf_events
    WHERE record_date IS NOT NULL
), compared AS (
    SELECT user_id, activity_date,
           LAG(activity_date, 2) OVER (
               PARTITION BY user_id ORDER BY activity_date
           ) AS two_dates_back
    FROM days
)
SELECT DISTINCT user_id
FROM compared
WHERE activity_date - two_dates_back = 2;

With unique ordered dates, three observations spanning two days must be consecutive. The gaps-and-islands version is more useful when asked for every streak or the longest streak.

References

Type a concept, keyword, or function.