Merging intervals — Collapse overlapping periods into one
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
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.
| window_id | service | starts_on | ends_on |
|---|---|---|---|
| 1 | api | 2026-05-01 | 2026-05-04 |
| 2 | api | 2026-05-03 | 2026-05-06 |
| 3 | api | 2026-05-06 | 2026-05-08 |
| 4 | api | 2026-05-12 | 2026-05-14 |
| 5 | web | 2026-05-02 | 2026-05-05 |
| 6 | web | 2026-05-09 | 2026-05-10 |
| service | starts_on | ends_on | windows |
|---|---|---|---|
| api | 2026-05-01 | 2026-05-08 | 3 |
| api | 2026-05-12 | 2026-05-14 | 1 |
| web | 2026-05-02 | 2026-05-05 | 1 |
| web | 2026-05-09 | 2026-05-10 | 1 |
Working days — Count days excluding weekends and holidays
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
<= 5 alone selects weekdays. With DOW (0=Sunday to 6=Saturday) you have to list the weekend as NOT IN (0, 6).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.
| task_id | start_on | due_on |
|---|---|---|
| 1 | 2026-05-01 | 2026-05-07 |
| 2 | 2026-05-04 | 2026-05-08 |
| 3 | 2026-05-08 | 2026-05-08 |
| holiday_on | name |
|---|---|
| 2026-05-04 | constitution day |
| 2026-05-06 | substitute holiday |
| task_id | start_on | due_on | business_days |
|---|---|---|---|
| 1 | 2026-05-01 | 2026-05-07 | 3 |
| 2 | 2026-05-04 | 2026-05-08 | 3 |
| 3 | 2026-05-08 | 2026-05-08 | 1 |
Cohort analysis — Line up retention by months since the first order
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
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.
| order_id | customer_id | ordered_on |
|---|---|---|
| 1 | 101 | 2026-01-10 |
| 2 | 101 | 2026-02-05 |
| 3 | 101 | 2026-03-20 |
| 4 | 102 | 2026-01-25 |
| 5 | 102 | 2026-03-02 |
| 6 | 103 | 2026-02-14 |
| 7 | 103 | 2026-03-01 |
| cohort_month | month_no | customers |
|---|---|---|
| 2026-01-01 | 0 | 2 |
| 2026-01-01 | 1 | 1 |
| 2026-01-01 | 2 | 2 |
| 2026-02-01 | 0 | 1 |
| 2026-02-01 | 1 | 1 |
Longest streak — Subtract a row number to build islands of consecutive days
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
DATE minus an integer moves back that many days. ROW_NUMBER() returns bigint, so cast it with ::int before subtracting.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.
| user_id | login_on |
|---|---|
| 101 | 2026-02-01 |
| 101 | 2026-02-02 |
| 101 | 2026-02-03 |
| 101 | 2026-02-06 |
| 101 | 2026-02-07 |
| 102 | 2026-02-01 |
| 102 | 2026-02-03 |
| 102 | 2026-02-04 |
| 103 | 2026-02-05 |
| user_id | started_on | ended_on | streak_days |
|---|---|---|---|
| 101 | 2026-02-01 | 2026-02-03 | 3 |
| 102 | 2026-02-03 | 2026-02-04 | 2 |
| 103 | 2026-02-05 | 2026-02-05 | 1 |
Hourly buckets — Prorate stays that cross the hour boundary
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);
EXTRACT(EPOCH FROM INTERVAL '1 hour') is 3600. Divide by 60 for minutes and by 3600 for hours.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.
| session_id | started_at | ended_at |
|---|---|---|
| 1 | 2026-10-01 09:10:00 | 2026-10-01 09:50:00 |
| 2 | 2026-10-01 09:40:00 | 2026-10-01 11:10:00 |
| 3 | 2026-10-01 11:30:00 | 2026-10-01 11:45:00 |
| bucket | minutes |
|---|---|
| 2026-10-01 09:00:00 | 60 |
| 2026-10-01 10:00:00 | 60 |
| 2026-10-01 11:00:00 | 25 |