Dates, intervals and generate_series
Date arithmetic that surprises people, half-open ranges instead of BETWEEN, and filling the gaps in a time report with generate_series.
After this lesson you can
- Predict the result type of date and interval arithmetic
- Filter a time range with a half-open interval
- Build a gap-free daily or monthly report with generate_series and a LEFT JOIN
- Explain timestamp versus timestamptz
Four types, and which to use
date— a calendar day, no time.timestamp(without time zone) — a wall-clock reading with no zone; it does not identify an instant on its own.timestamptz(with time zone) — an instant. PostgreSQL stores it in UTC and displays it in the session'sTimeZonesetting; it does not store the zone you typed. This is the right type for "when did it happen". (This sandbox's session zone is UTC.)interval— a duration in months, days and microseconds, kept separately because a month is not a fixed number of days.
generate_series and its result type
generate_series(start, stop, step) returns one row per step. With
integers it returns integers. With dates and an interval step, there is
no date version of the function, so the dates are promoted and you get
timestamptz back. Cast to date when you mean days.
SELECT pg_typeof(g) AS raw_type, g::text AS raw_value, g::date::text AS as_dateFROM generate_series(date '2024-01-30', date '2024-02-02', interval '1 day') AS g;Filling gaps in a report
Grouping by day only produces days that have rows. A chart built from
that silently skips quiet days. Generate the calendar first, then
LEFT JOIN the facts onto it and COALESCE the empty sums to 0.
CREATE TABLE orders (id int PRIMARY KEY, placed_at timestamptz NOT NULL, amount numeric(10,2) NOT NULL);INSERT INTO orders VALUES (1, '2024-03-01 09:15+00', 20.00), (2, '2024-03-01 17:40+00', 5.50), (3, '2024-03-03 11:00+00', 12.00), (4, '2024-03-05 23:59+00', 8.00);SELECT d::date::text AS day, count(o.id) AS orders, COALESCE(sum(o.amount), 0) AS revenueFROM generate_series(date '2024-03-01', date '2024-03-05', interval '1 day') AS dLEFT JOIN orders o ON o.placed_at >= d AND o.placed_at < d + interval '1 day'GROUP BY dORDER BY d;Arithmetic and ranges
date - dateis anintegernumber of days;date + integeris a date.date + intervalis atimestamp. Addinginterval '1 month'to 31 January clamps to the last day of February.date_trunc('month', x)truncates to the start of the unit; on adateit returnstimestamptz.to_char(x, 'YYYY-MM')formats for display.AT TIME ZONEconverts an instant to the wall-clock time in a zone.
For ranges, use a half-open interval: at >= start AND at < next_start.
BETWEEN '2024-01-01' AND '2024-12-31' on a timestamp means "up to
midnight at the start of 31 December", so it drops almost all of the
last day.
CREATE TABLE events (id int PRIMARY KEY, at timestamp NOT NULL);INSERT INTO events VALUES (1, '2024-12-31 00:00'), (2, '2024-12-31 18:30'), (3, '2025-01-01 00:00');SELECT date '2024-03-01' - date '2024-02-01' AS days_in_feb, (date '2024-01-31' + interval '1 month')::text AS jan31_plus_month, (date '2024-01-31' + 30)::text AS jan31_plus_30, date_trunc('month', timestamp '2024-02-17 13:45')::text AS month_start, pg_typeof(date_trunc('month', date '2024-02-17')) AS trunc_of_date_type, to_char(timestamp '2024-02-17 13:45', 'YYYY-MM') AS year_month, (timestamptz '2024-03-10 12:00+00' AT TIME ZONE 'America/New_York')::text AS new_york_wall_clock, (SELECT string_agg(id::text, ',' ORDER BY id) FROM events WHERE at BETWEEN '2024-01-01' AND '2024-12-31') AS between_ids, (SELECT string_agg(id::text, ',' ORDER BY id) FROM events WHERE at >= '2024-01-01' AND at < '2025-01-01') AS half_open_ids;Common mistakes
BETWEENwith a date upper bound on a timestamp column, losing the last day of the range.- Joining the calendar to a timestamp column with
=(o.placed_at = d), which only matches orders placed exactly at midnight. Join on a range. INNER JOINfrom the calendar to the facts, which deletes the empty days the calendar was there to produce.- Storing event times as
timestampand then not knowing which zone a value was written in.
What interviewers ask
- timestamp versus timestamptz?
timestamptzis an absolute instant stored as UTC and shown in the session zone;timestampis an unanchored wall-clock value. - How do you include days with no sales in a daily report?
generate_seriesfor the calendar,LEFT JOINthe facts,COALESCE(sum(...), 0). - Why avoid BETWEEN for time ranges? It is inclusive at both ends; half-open ranges compose without overlaps or gaps.
Exercise · Pro
Revenue for every month of 2024
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade