Weekly rollup — Shift the start of the week to Sunday
DATE_TRUNC('week', ...) always starts the week on Monday, and there is no option to change it. To start on Sunday, shift the date by one day, truncate, then shift back by the same amount.
SELECT (DATE_TRUNC('week', date_col + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start FROM table_name; -- moving a Sunday forward makes it a Monday, so it lands in the same week
Aggregate the signups table into weeks that start on Sunday, and return the first day of each week together with the number of signups. Return week_start, signups, sorted by week_start ascending.
| signup_id | channel | signed_up_on |
|---|---|---|
| 1 | web | 2026-03-01 |
| 2 | web | 2026-03-02 |
| 3 | app | 2026-03-07 |
| 4 | app | 2026-03-08 |
| 5 | web | 2026-03-09 |
| 6 | web | 2026-03-14 |
| 7 | app | 2026-03-15 |
| week_start | signups |
|---|---|
| 2026-03-01 | 3 |
| 2026-03-08 | 3 |
| 2026-03-15 | 1 |
SELECT (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start, -- shift, truncate, shift back COUNT(*) AS signups FROM signups GROUP BY (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date ORDER BY week_start; /* Execution order (logical evaluation order): 1. FROM signups → Read 7 rows 2. GROUP BY shifted week start → Split into 3 groups 3. SELECT COUNT(*) → Count per group 4. ORDER BY week_start → Ascending by first day of week */
LEGEND
① FROM signups
FROM signupsRead all 7 rows of signups. 2026-03-01, 2026-03-08 and 2026-03-15 are all Sundays, which is exactly where the choice of week start changes the result. Weekday is not a column of the table; it is shown only to make those boundaries easy to see.| signup_id | channel | signed_up_on | Weekday |
|---|---|---|---|
| 1 | web | 2026-03-01 | Sun |
| 2 | web | 2026-03-02 | Mon |
| 3 | app | 2026-03-07 | Sat |
| 4 | app | 2026-03-08 | Sun |
| 5 | web | 2026-03-09 | Mon |
| 6 | web | 2026-03-14 | Sat |
| 7 | app | 2026-03-15 | Sun |
INTERVAL '2 days' in both places.EXTRACT(WEEK ...) keeps sorting chronological across year boundaries. Add a display label such as "week N" in the report layer.EXTRACT(WEEK ...) follows ISO 8601, where weeks start on Monday and week 1 is the week containing the first Thursday. Around the new year it mixes in week 52 of the previous year and week 1 of the next, so it cannot express a Sunday-start business rule.signed_up_on - EXTRACT(DOW FROM signed_up_on) depend on integer/date arithmetic rules and are hard to read. Leave the rounding to DATE_TRUNC and make only the shift explicit.Prorating a period — Count the days a contract overlaps a target month
Where two intervals overlap, the start is the later of the two starts and the end is the earlier of the two ends. GREATEST and LEAST express both ends without any case analysis.
SELECT GREATEST(start_col, DATE '2025-01-01') AS from_date, -- the later start LEAST(end_col, DATE '2025-02-01') AS to_date -- the earlier end FROM table_name;
[start, end), the number of days is simply end - start. No +1 correction is needed, and adjacent periods can be summed without double counting.Contracts in the subscriptions table are half-open intervals [started_on, ended_on), and a NULL ended_on means the contract is still active. For each contract, compute the number of days billable in May 2026. Exclude contracts that do not overlap May at all. Return sub_id, plan, billed_from, billed_to, billed_days, sorted by sub_id ascending. billed_to is the exclusive end of the interval — the first day that is not billed.
| sub_id | plan | started_on | ended_on |
|---|---|---|---|
| 1 | basic | 2026-04-20 | 2026-05-10 |
| 2 | pro | 2026-05-05 | 2026-05-25 |
| 3 | pro | 2026-05-25 | NULL |
| 4 | basic | 2026-03-01 | 2026-04-15 |
| 5 | basic | 2026-04-01 | 2026-06-10 |
| sub_id | plan | billed_from | billed_to | billed_days |
|---|---|---|---|---|
| 1 | basic | 2026-05-01 | 2026-05-10 | 9 |
| 2 | pro | 2026-05-05 | 2026-05-25 | 20 |
| 3 | pro | 2026-05-25 | 2026-06-01 | 7 |
| 5 | basic | 2026-05-01 | 2026-06-01 | 31 |
SELECT sub_id, plan, GREATEST(started_on, DATE '2026-05-01') AS billed_from, -- later of month start and contract start LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') AS billed_to, -- active means "runs to next month" LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') - GREATEST(started_on, DATE '2026-05-01') AS billed_days -- half-open, so the difference is the day count FROM subscriptions WHERE started_on < DATE '2026-06-01' -- drop contracts starting after May AND COALESCE(ended_on, DATE '2026-06-01') > DATE '2026-05-01' -- drop contracts ending before May ORDER BY sub_id; /* Execution order (logical evaluation order): 1. FROM subscriptions → Read 5 rows 2. WHERE overlaps May → Narrow to 4 rows 3. SELECT GREATEST / LEAST → Fix both ends of the overlap 4. SELECT end - start → Compute billable days 5. ORDER BY sub_id → Ascending by contract id */
LEGEND
① FROM subscriptions
FROM subscriptionsRead all 5 rows of subscriptions. The mix contains contracts that span May, one that starts inside May, and one that is still active with a NULL end date.| sub_id | plan | started_on | ended_on |
|---|---|---|---|
| 1 | basic | 2026-04-20 | 2026-05-10 |
| 2 | pro | 2026-05-05 | 2026-05-25 |
| 3 | pro | 2026-05-25 | NULL |
| 4 | basic | 2026-03-01 | 2026-04-15 |
| 5 | basic | 2026-04-01 | 2026-06-10 |
GREATEST / LEAST gives the intersection. No CASE analysis over contract shapes is needed.COALESCE(ended_on, end of the reporting window) lets active and finished contracts share one expression.end - start is the day count. A closed interval needs a +1 correction and invites double counting when months are summed.GREATEST / LEAST.ended_on > DATE '2026-05-01' makes active contracts evaluate to UNKNOWN, and they silently disappear from the invoice run. Comparisons on nullable columns need either a replacement or an explicit IS NULL.LAG and sessionization — Split an event stream after 30 idle minutes
LAG pulls in the timestamp of the previous row, which lets you measure the gap between consecutive events. Raise a flag of 1 on rows whose gap reaches the threshold, and a running total of those flags becomes a group number. Window functions cannot be nested inside one another, so the flag and the running total belong to separate query levels.
SELECT SUM(is_new) OVER (PARTITION BY key_col ORDER BY ts_col) AS grp FROM ( SELECT key_col, ts_col, CASE WHEN ts_col - LAG(ts_col) OVER (PARTITION BY key_col ORDER BY ts_col) >= INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new FROM table_name) t;
LAG returns NULL on the first row, and the comparison is NULL as well — not true. The flag therefore stays 0 and the running total starts at 0.Group the events in app_events into sessions, starting a new session wherever 30 minutes or more elapsed since the previous event. Number the sessions from 1 per user. Return user_id, session_no, started_at, ended_at, events, sorted by user_id then session_no ascending.
| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 101 | 2026-08-10 09:00:00 |
| 2 | 101 | 2026-08-10 09:12:00 |
| 3 | 101 | 2026-08-10 10:05:00 |
| 4 | 101 | 2026-08-10 10:20:00 |
| 5 | 102 | 2026-08-10 09:30:00 |
| 6 | 102 | 2026-08-10 11:00:00 |
| user_id | session_no | started_at | ended_at | events |
|---|---|---|---|---|
| 101 | 1 | 2026-08-10 09:00:00 | 2026-08-10 09:12:00 | 2 |
| 101 | 2 | 2026-08-10 10:05:00 | 2026-08-10 10:20:00 | 2 |
| 102 | 1 | 2026-08-10 09:30:00 | 2026-08-10 09:30:00 | 1 |
| 102 | 2 | 2026-08-10 11:00:00 | 2026-08-10 11:00:00 | 1 |
WITH marked AS ( SELECT event_id, user_id, occurred_at, CASE WHEN occurred_at - LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at) >= INTERVAL '30 minutes' THEN 1 ELSE 0 END AS is_new -- flag the boundaries FROM app_events ), numbered AS ( SELECT user_id, occurred_at, SUM(is_new) OVER (PARTITION BY user_id ORDER BY occurred_at) + 1 AS session_no -- running total of flags FROM marked ) SELECT user_id, session_no, MIN(occurred_at) AS started_at, MAX(occurred_at) AS ended_at, COUNT(*) AS events FROM numbered GROUP BY user_id, session_no ORDER BY user_id, session_no; /* Execution order (logical evaluation order): 1. FROM app_events → Read 6 rows 2. LAG for the previous time → Compute gaps and raise flags 3. SUM(...) OVER running total → Fix the session number 4. GROUP BY user_id, session_no → Collapse to one row per session 5. ORDER BY user_id, session_no → Ascending by user and number */
LEGEND
① FROM app_events
FROM app_eventsRead all 6 rows of app_events. User 101 has 4 events and user 102 has 2, both already in time order.| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 101 | 2026-08-10 09:00:00 |
| 2 | 101 | 2026-08-10 09:12:00 |
| 3 | 101 | 2026-08-10 10:05:00 |
| 4 | 101 | 2026-08-10 10:20:00 |
| 5 | 102 | 2026-08-10 09:30:00 |
| 6 | 102 | 2026-08-10 11:00:00 |
TIMESTAMP - TIMESTAMP yields an INTERVAL, so compare it against INTERVAL '30 minutes' to keep the types aligned. Use EXTRACT(EPOCH FROM …) when you need seconds instead.PARTITION BY user_id, the gap to another user's last event decides where a session breaks.is_new inside SUM(is_new) OVER … cannot be defined at the same level. Move it one step down into a CTE or subquery and reference the finished column.Moving-average frames — Cut seven days by dates, not by row count
A window frame can be defined by row count (ROWS) or by a range of values (RANGE). When the ORDER BY key is a date, the width of a RANGE frame can be written as an INTERVAL.
SELECT AVG(num_col) OVER ( ORDER BY date_col RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW) -- seven days including today FROM table_name;
ROWS 6 PRECEDING means "the previous six rows", so a table with missing days averages more than seven days of history. RANGE looks at the dates themselves, so the window stays seven days wide even when rows are absent.For each row of daily_sales, return the amount together with a seven-day moving average including that day. The window must be seven calendar days, and days with no row simply had no sales — they are not counted in the denominator. Round the average to one decimal place. Return sold_on, amount, avg_7d, sorted by sold_on ascending.
| sold_on | amount |
|---|---|
| 2026-09-01 | 100 |
| 2026-09-02 | 200 |
| 2026-09-03 | 300 |
| 2026-09-06 | 600 |
| 2026-09-07 | 700 |
| 2026-09-08 | 800 |
| sold_on | amount | avg_7d |
|---|---|---|
| 2026-09-01 | 100 | 100.0 |
| 2026-09-02 | 200 | 150.0 |
| 2026-09-03 | 300 | 200.0 |
| 2026-09-06 | 600 | 300.0 |
| 2026-09-07 | 700 | 380.0 |
| 2026-09-08 | 800 | 520.0 |
SELECT sold_on, amount, ROUND(AVG(amount) OVER ( ORDER BY sold_on RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW), 1) AS avg_7d -- seven days by date FROM daily_sales ORDER BY sold_on; /* Execution order (logical evaluation order): 1. FROM daily_sales → Read 6 rows 2. OVER (ORDER BY sold_on) → Order by date 3. RANGE INTERVAL '6 days' → Fix a seven-day window per row 4. AVG + ROUND → Average the window to one decimal 5. ORDER BY sold_on → Ascending by date */
LEGEND
① FROM daily_sales
FROM daily_salesRead all 6 rows of daily_sales. September 4 and 5 have no rows at all, so the dates jump.| sold_on | amount |
|---|---|
| 2026-09-01 | 100 |
| 2026-09-02 | 200 |
| 2026-09-03 | 300 |
| 2026-09-06 | 600 |
| 2026-09-07 | 700 |
| 2026-09-08 | 800 |
RANGE ... INTERVAL works when the ORDER BY key is a date or timestamp and the offset can be added to it. For a numeric key, write the offset in the same type, as in RANGE BETWEEN 100 PRECEDING.ROWS BETWEEN 6 PRECEDING can average ten calendar days or more. Use RANGE when seven days is meant in the calendar sense.LATERAL for last-known values — Rebuild the daily price from a change log
A subquery marked LATERAL can reference values from each row on its left. Combined with a spine of dates, it fetches "the value last in effect on that day", one row at a time.
SELECT d.day, x.val FROM generate_series(...) AS d(day) LEFT JOIN LATERAL ( SELECT h.val FROM history_table h WHERE h.changed_on <= d.day -- can reference the row on the left ORDER BY h.changed_on DESC LIMIT 1) x ON TRUE;
WHERE, so ON is simply TRUE. LIMIT 1 guarantees at most one right-hand row per left-hand row.The price_changes table records only the days on which the price changed. For the five days from 2026-07-01 to 2026-07-05, return the price in effect on each day — the price from the most recent change on or before that day. Return day, price, sorted by day ascending.
| changed_on | price |
|---|---|
| 2026-06-20 | 1000 |
| 2026-07-02 | 1200 |
| 2026-07-04 | 900 |
| day | price |
|---|---|
| 2026-07-01 | 1000 |
| 2026-07-02 | 1200 |
| 2026-07-03 | 1200 |
| 2026-07-04 | 900 |
| 2026-07-05 | 900 |
SELECT d.day::date AS day, p.price FROM generate_series( DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day') AS d(day) -- spine of dates (5 rows) LEFT JOIN LATERAL ( SELECT c.price FROM price_changes c WHERE c.changed_on <= d.day::date -- only changes up to that day ORDER BY c.changed_on DESC LIMIT 1) p ON TRUE -- take the most recent one ORDER BY day; /* Execution order (logical evaluation order): 1. generate_series(...) → Generate 5 days 2. LATERAL subquery per row → Latest change on or before that day 3. LEFT JOIN ... ON TRUE → Attach the price to the day 4. SELECT day, price → Select 2 columns 5. ORDER BY day → Ascending by date */
LEGEND
① generate_series builds the dates
generate_series(DATE '2026-07-01', DATE '2026-07-05', INTERVAL '1 day')Create five rows from July 1 to July 5. The change log alone would give only three rows, so this spine decides the row count of the result.| d.day |
|---|
| 2026-07-01 |
| 2026-07-02 |
| 2026-07-03 |
| 2026-07-04 |
| 2026-07-05 |
LATERAL with ORDER BY … DESC LIMIT 1 is the standard shape for that.LEFT JOIN keeps the day with a NULL price, whereas a plain JOIN removes it from the result. Making the gap visible is the safer choice.LAST_VALUE or MAX(...) OVER. The LATERAL version states "read exactly one row per day" more clearly and lets an index on (changed_on) work when the history is large.ORDER BY changed_on DESC and add a tie-breaking column in case of two changes on one day.valid_from and valid_to columns. The value for a day is then a simple join on day >= valid_from AND day < valid_to, and the last-known-value search in this question disappears. The price is more write work — every change updates the previous row's valid_to — plus constraints to keep periods from overlapping or leaving gaps. Whether to favour easy reads or simple writes depends on how often the data changes versus how often it is read.