SQL Date and Time — Applied Merges, Workdays, Streaks

ADVDate and TimeInterval mergingWorking daysCohortsStreaksHourly bucketsPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 6

Merging intervals — Collapse overlapping periods into one

Window functionRunning maximumInterval mergingGap detection
Background

To merge overlapping periods, order them by start and compare each start against "the largest end reached so far". If the current start goes past it, that is the beginning of a new period.

SELECT MAX(end_col) OVER (
         PARTITION BY key_col ORDER BY start_col, end_col
         ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
FROM table_name;
-- the end reached by rows strictly before this one
Compare against the running maximum, not the previous row: looking only at the end of the immediately preceding period misses the case where a short period sits entirely inside a long one. The comparison must be against the largest end so far.
Problem

The maintenance windows in the maintenance table are half-open intervals [starts_on, ends_on). Per service, collapse windows that overlap or run back to back into a single period. When the end of one window equals the start of the next, treat them as continuous and merge them. Return service, starts_on, ends_on, windows (how many windows were merged), sorted by service then starts_on ascending.

Tables used
▸ maintenance
window_idservicestarts_onends_on
1api2026-05-012026-05-04
2api2026-05-032026-05-06
3api2026-05-062026-05-08
4api2026-05-122026-05-14
5web2026-05-022026-05-05
6web2026-05-092026-05-10
Expected Output
servicestarts_onends_onwindows
api2026-05-012026-05-083
api2026-05-122026-05-141
web2026-05-022026-05-051
web2026-05-092026-05-101
QUESTION 7

Working days — Count days excluding weekends and holidays

generate_seriesLATERALWorking daysNOT EXISTS
Background

Counting working days means expanding a period one day at a time and counting the days that qualify. Because the expansion depends on each left-hand row, put generate_series inside a CROSS JOIN LATERAL subquery.

SELECT t.id, x.days
FROM   table_name t
CROSS JOIN LATERAL (
  SELECT COUNT(*) AS days
  FROM   generate_series(t.start_col, t.end_col, INTERVAL '1 day') AS d(day)
  WHERE  EXTRACT(ISODOW FROM d.day) <= 5) x;   -- 1=Mon … 5=Fri
ISODOW suits weekday tests: it runs 1=Monday to 7=Sunday, so <= 5 alone selects weekdays. With DOW (0=Sunday to 6=Saturday) you have to list the weekend as NOT IN (0, 6).
Problem

For each task in tasks, compute the number of working days from the start date through the due date. A working day is Monday through Friday that does not appear in the holidays table, and both the start date and the due date are included. Return task_id, start_on, due_on, business_days, sorted by task_id ascending.

Tables used
▸ tasks
task_idstart_ondue_on
12026-05-012026-05-07
22026-05-042026-05-08
32026-05-082026-05-08
▸ holidays
holiday_onname
2026-05-04constitution day
2026-05-06substitute holiday
Expected Output
task_idstart_ondue_onbusiness_days
12026-05-012026-05-073
22026-05-042026-05-083
32026-05-082026-05-081
QUESTION 8

Cohort analysis — Line up retention by months since the first order

MIN + DATE_TRUNCMonths elapsedCohortEXTRACT
Background

The distance between two months is the difference in years times twelve plus the difference in months. Subtracting dates gives days, which do not line up because months have different lengths.

SELECT (EXTRACT(YEAR FROM date_col) - EXTRACT(YEAR FROM base_col)) * 12
     + (EXTRACT(MONTH FROM date_col) - EXTRACT(MONTH FROM base_col)) AS month_no
FROM table_name;
-- 2025-04 and 2025-06 → 2; 2025-12 and 2026-02 → 2
The baseline differs per row: in cohort analysis the baseline month is each customer's own first month. Because that comes from an aggregate but is needed per row, compute it in a CTE and join it back.
Problem

Using the orders table, treat each customer's first order month as their cohort and count, for every number of months since the cohort month, how many customers placed an order. The first month is 0. Return cohort_month, month_no, customers, sorted by cohort_month then month_no ascending. Express cohort_month as the first day of the month.

Tables used
▸ orders
order_idcustomer_idordered_on
11012026-01-10
21012026-02-05
31012026-03-20
41022026-01-25
51022026-03-02
61032026-02-14
71032026-03-01
Expected Output
cohort_monthmonth_nocustomers
2026-01-0102
2026-01-0111
2026-01-0122
2026-02-0101
2026-02-0111
QUESTION 9

Longest streak — Subtract a row number to build islands of consecutive days

ROW_NUMBERIsland groupingConsecutive daysDISTINCT ON
Background

Subtracting a sequential number from consecutive dates gives the same value throughout a run. When a date is skipped the difference changes, so the value itself identifies an "island" of consecutive days.

SELECT date_col - (ROW_NUMBER() OVER (PARTITION BY key_col ORDER BY date_col))::int AS grp
FROM   table_name;
-- 07-11, 07-12, 07-13 → same grp / 07-16 → a different grp
Subtracting an integer from a date: a DATE minus an integer moves back that many days. ROW_NUMBER() returns bigint, so cast it with ::int before subtracting.
Problem

From the logins table, find each user's longest run of consecutive login days together with the first and last day of that run. If several runs tie for the longest, take the one that starts earliest. Return user_id, started_on, ended_on, streak_days, sorted by user_id ascending.

Tables used
▸ logins
user_idlogin_on
1012026-02-01
1012026-02-02
1012026-02-03
1012026-02-06
1012026-02-07
1022026-02-01
1022026-02-03
1022026-02-04
1032026-02-05
Expected Output
user_idstarted_onended_onstreak_days
1012026-02-012026-02-033
1022026-02-032026-02-042
1032026-02-052026-02-051
QUESTION 10

Hourly buckets — Prorate stays that cross the hour boundary

generate_seriesGREATEST / LEASTHourly rollupEPOCH
Background

To assign intervals that cross hour boundaries, build a spine of buckets and measure how much each interval overlaps each bucket. GREATEST and LEAST give the overlap, and EXTRACT(EPOCH FROM ...) turns it into seconds.

SELECT EXTRACT(EPOCH FROM (
         LEAST(end_col, b.bucket + INTERVAL '1 hour')
       - GREATEST(start_col, b.bucket))) / 60 AS minutes
FROM table_name, generate_series(...) AS b(bucket);
EPOCH is in seconds: EXTRACT(EPOCH FROM INTERVAL '1 hour') is 3600. Divide by 60 for minutes and by 3600 for hours.
Problem

Prorate the stays in the sessions table into one-hour buckets for 09:00, 10:00 and 11:00 and sum them. Each bucket is the half-open interval [start, start + 1 hour), and sessions that do not overlap a bucket are not counted in it. Return bucket, minutes (total minutes as an integer), sorted by bucket ascending.

Tables used
▸ sessions
session_idstarted_atended_at
12026-10-01 09:10:002026-10-01 09:50:00
22026-10-01 09:40:002026-10-01 11:10:00
32026-10-01 11:30:002026-10-01 11:45:00
Expected Output
bucketminutes
2026-10-01 09:00:0060
2026-10-01 10:00:0060
2026-10-01 11:00:0025