SQL Date and Time — Applied Week Starts, Sessions, Averages

ADVDate and TimeWeek startPeriod prorationLAG / sessionizationMoving averageLATERAL fillPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

Weekly rollup — Shift the start of the week to Sunday

DATE_TRUNCWeekly rollupWeek startISO week
Background

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
Shift in the right direction: you want Sunday at the head of the week, so move each date forward before truncating and move the result back. Reversing the direction gives you weeks that start on Saturday.
Problem

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.

Tables used
▸ signups
signup_idchannelsigned_up_on
1web2026-03-01
2web2026-03-02
3app2026-03-07
4app2026-03-08
5web2026-03-09
6web2026-03-14
7app2026-03-15
Expected Output
week_startsignups
2026-03-013
2026-03-083
2026-03-151
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date AS week_start, COUNT(*) AS signups FROM signups GROUP BY (DATE_TRUNC('week', signed_up_on + INTERVAL '1 day') - INTERVAL '1 day')::date ORDER BY week_start;
LEGEND
Rows read / loaded
① 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.
1 / 6
signup_idchannelsigned_up_onWeekday
1web2026-03-01Sun
2web2026-03-02Mon
3app2026-03-07Sat
4app2026-03-08Sun
5web2026-03-09Mon
6web2026-03-14Sat
7app2026-03-15Sun
All 7 rows read
LEARNING POINTS
Shift, truncate, shift back: the general recipe for changing the week start is "move forward by the offset, truncate, move back by the same offset". For Saturday weeks use INTERVAL '2 days' in both places.
Key on the first day, not the week number: using a date rather than EXTRACT(WEEK ...) keeps sorting chronological across year boundaries. Add a display label such as "week N" in the report layer.
One boundary day moves the numbers: the same data gives different weekly counts under Monday and Sunday weeks. Any week-over-week metric loses continuity at the moment the definition changes.
ANTI-PATTERNS
Expressing Sunday weeks with ISO week numbers: 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.
Computing the week start from the weekday number: expressions such as 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.
Field Notes
The definition of a week differs from team to team: retail often uses Sunday weeks, engineering and manufacturing follow the ISO Monday week, and finance uses calendars such as 4-4-5 that fix the number of weeks in a month. Because the same company can hold several definitions at once, always ship a weekly report with its definition attached. For long-running reporting, build a calendar table that stores the week start, fiscal month and quarter for every date, and have every query read it — then a difference in definition can no longer show up as a difference in the numbers.
QUESTION 2

Prorating a period — Count the days a contract overlaps a target month

GREATEST / LEASTCOALESCEDaily prorationHalf-open interval
Background

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;
Hold the end as "the start of the next period": with half-open intervals [start, end), the number of days is simply end - start. No +1 correction is needed, and adjacent periods can be summed without double counting.
Problem

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.

Tables used
▸ subscriptions
sub_idplanstarted_onended_on
1basic2026-04-202026-05-10
2pro2026-05-052026-05-25
3pro2026-05-25NULL
4basic2026-03-012026-04-15
5basic2026-04-012026-06-10
Expected Output
sub_idplanbilled_frombilled_tobilled_days
1basic2026-05-012026-05-109
2pro2026-05-052026-05-2520
3pro2026-05-252026-06-017
5basic2026-05-012026-06-0131
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT sub_id, plan, GREATEST(started_on, DATE '2026-05-01') AS billed_from, LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') AS billed_to, LEAST(COALESCE(ended_on, DATE '2026-06-01'), DATE '2026-06-01') - GREATEST(started_on, DATE '2026-05-01') AS billed_days FROM subscriptions WHERE started_on < DATE '2026-06-01' AND COALESCE(ended_on, DATE '2026-06-01') > DATE '2026-05-01' ORDER BY sub_id;
LEGEND
Rows read / loaded
① 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.
1 / 6
sub_idplanstarted_onended_on
1basic2026-04-202026-05-10
2pro2026-05-052026-05-25
3pro2026-05-25NULL
4basic2026-03-012026-04-15
5basic2026-04-012026-06-10
All 5 rows read
LEARNING POINTS
The overlap is "later start, earlier end": whether one interval contains the other, partly overlaps it, or matches it exactly, the pair GREATEST / LEAST gives the intersection. No CASE analysis over contract shapes is needed.
Replace NULL with "the far future": a NULL that means "still active" can neither be compared nor subtracted. Reading it as COALESCE(ended_on, end of the reporting window) lets active and finished contracts share one expression.
Half-open intervals make the count a subtraction: when the end is "the first day that is not billed", end - start is the day count. A closed interval needs a +1 correction and invites double counting when months are summed.
ANTI-PATTERNS
Writing a CASE per contract shape: enumerating "starts before the month", "ends inside the month" and so on turns any missing branch directly into a billing error. Leave the clipping to GREATEST / LEAST.
Letting NULL flow through the condition: writing only 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.
Field Notes
Choosing the denominator for proration: once you have the overlapping days, the next decision is what to divide by. Dividing by the number of days in that month makes the daily rate change month to month; dividing by a fixed 30 makes February more expensive; dividing an annual fee by 365 is another option. Which one is correct is a matter of the pricing terms, so check the contract wording before the SQL. Refunds and mid-term cancellations must use the same denominator, or a one-day gap will remain between what you billed and what you refunded.
QUESTION 3

LAG and sessionization — Split an event stream after 30 idle minutes

LAGWindow functionSessionizationRunning total
Background

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;
The first row of each group is NULL: 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.
Problem

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.

Tables used
▸ app_events
event_iduser_idoccurred_at
11012026-08-10 09:00:00
21012026-08-10 09:12:00
31012026-08-10 10:05:00
41012026-08-10 10:20:00
51022026-08-10 09:30:00
61022026-08-10 11:00:00
Expected Output
user_idsession_nostarted_atended_atevents
10112026-08-10 09:00:002026-08-10 09:12:002
10122026-08-10 10:05:002026-08-10 10:20:002
10212026-08-10 09:30:002026-08-10 09:30:001
10222026-08-10 11:00:002026-08-10 11:00:001
Model Answer
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
  */
Explanation (table transitions & key points)
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 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 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;
LEGEND
Rows read / loaded
① 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.
1 / 6
event_iduser_idoccurred_at
11012026-08-10 09:00:00
21012026-08-10 09:12:00
31012026-08-10 10:05:00
41012026-08-10 10:20:00
51022026-08-10 09:30:00
61022026-08-10 11:00:00
All 6 rows read
LEARNING POINTS
Flag, then accumulate: "raise 1 at a boundary and turn the running total into a group number" is the standard two-step for cutting a time series. Changing only the threshold turns 30-minute sessions into 24-hour ones with the same shape.
Compare INTERVAL with INTERVAL: 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.
Do not forget PARTITION BY: both the boundary test and the running total must be closed per user. Without PARTITION BY user_id, the gap to another user's last event decides where a session breaks.
ANTI-PATTERNS
Reusing a window result in the same SELECT: the 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.
Splitting the stream in application code: fetching every event and cutting it in a loop increases transfer volume and scatters the threshold across the UI, batch jobs and the analytics stack. Write the rule once, in the query.
Field Notes
Where the number 30 comes from: the widely used "a session ends after 30 idle minutes" rule is simply the default of the analytics tools that became a de facto standard. It is too short for services such as video, where gaps between interactions are natural, and too long for a checkout flow that completes in minutes. Keep the threshold as an external parameter so that historical data can be recomputed under the same definition when it changes. Session counts often serve as the denominator of a KPI, so record any change of definition as a break in the before-and-after comparison.
QUESTION 4

Moving-average frames — Cut seven days by dates, not by row count

RANGE frameWindow functionMoving averageMissing days
Background

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;
The two differ once rows are missing: 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.
Problem

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.

Tables used
▸ daily_sales
sold_onamount
2026-09-01100
2026-09-02200
2026-09-03300
2026-09-06600
2026-09-07700
2026-09-08800
Expected Output
sold_onamountavg_7d
2026-09-01100100.0
2026-09-02200150.0
2026-09-03300200.0
2026-09-06600300.0
2026-09-07700380.0
2026-09-08800520.0
Model Answer
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
  */
Explanation (table transitions & key points)
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 FROM daily_sales ORDER BY sold_on;
LEGEND
Rows read / loaded
① 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.
1 / 4
sold_onamount
2026-09-01100
2026-09-02200
2026-09-03300
2026-09-06600
2026-09-07700
2026-09-08800
All 6 rows read
LEARNING POINTS
ROWS counts rows, RANGE measures values: the two agree while the dates are contiguous and diverge as soon as a day is missing. When someone says "the last seven days", they mean the width in dates.
An INTERVAL frame follows the ORDER BY type: 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.
The first rows have short windows: the earliest rows lack seven days of history and are averaged over fewer values. If the ramp-up looks wrong on a chart, either return NULL until seven days exist or start the display later.
ANTI-PATTERNS
Counting rows with ROWS despite missing days: in a table where closed days leave no rows, ROWS BETWEEN 6 PRECEDING can average ten calendar days or more. Use RANGE when seven days is meant in the calendar sense.
Being vague about zero-filling: leaving a day without sales as no row keeps it out of the denominator, while filling it with 0 puts it in and lowers the average. Which is correct depends on the metric, and mixing both makes periods incomparable.
Field Notes
A moving average is a tool for removing the weekday cycle: daily KPIs look completely different on weekdays and weekends, so a raw line chart hides the trend. A seven-day moving average smooths exactly one week and cancels the weekday cycle, leaving only the direction of change. That is why the width has to be seven — five or ten days leave part of the cycle intact. For monthly trends use 28 days (four weeks) rather than 30. Choose the width as an integer multiple of the cycle you want to remove, and the choice stops being arbitrary.
QUESTION 5

LATERAL for last-known values — Rebuild the daily price from a change log

LATERALgenerate_seriesLast-known valueHistory table
Background

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;
ON TRUE and LIMIT 1 come as a pair: the join condition already lives in the subquery's WHERE, so ON is simply TRUE. LIMIT 1 guarantees at most one right-hand row per left-hand row.
Problem

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.

Tables used
▸ price_changes
changed_onprice
2026-06-201000
2026-07-021200
2026-07-04900
Expected Output
dayprice
2026-07-011000
2026-07-021200
2026-07-031200
2026-07-04900
2026-07-05900
Model Answer
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
  */
Explanation (table transitions & key points)
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) LEFT JOIN LATERAL ( SELECT c.price FROM price_changes c WHERE c.changed_on <= d.day::date ORDER BY c.changed_on DESC LIMIT 1) p ON TRUE ORDER BY day;
LEGEND
Rows read / loaded
① 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.
1 / 6
d.day
2026-07-01
2026-07-02
2026-07-03
2026-07-04
2026-07-05
5 rows generated
LEARNING POINTS
A history table records only what changed: storing change days alone wastes nothing, but recovering "the value on a given day" requires walking back to the last known value. LATERAL with ORDER BY … DESC LIMIT 1 is the standard shape for that.
Why LEFT JOIN: for days before the first recorded change the subquery returns no rows. 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.
A window function also works: you can join history to dates and fill with 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.
ANTI-PATTERNS
Aggregating the history table directly: aggregating only the days that have rows drops July 3 and July 5 from the result. A missing row does not mean "no value that day", it means "the value did not change".
Using LIMIT 1 without ORDER BY: without an explicit order, which row comes back is undefined. For a last-known value, write ORDER BY changed_on DESC and add a tie-breaking column in case of two changes on one day.
Field Notes
Validity periods as an alternative design: instead of recording only change dates, a history table can carry 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.