SQL Date Functions: DATE_TRUNC, EXTRACT, Interval Math, and Time Zone Traps

Use PostgreSQL date functions safely across time zones, month buckets, ISO calendars, intervals, half-open windows, and dense rolling periods.

Author: PracHub

Published: 8/14/2026

SQL Date Functions: DATE_TRUNC, EXTRACT, Interval Math, and Time Zone Traps

August 14, 2026
24 min read
SQL Date Functions: DATE_TRUNC, EXTRACT, Interval Math, and Time Zone Traps

Quick Overview

A Data Scientist guide to PostgreSQL date and time analysis. Seven executed examples cover explicit reporting zones, DATE_TRUNC, ISO EXTRACT fields, date and interval arithmetic, half-open local windows, and dense rolling-week spines.

Data ScientistFree

Date logic starts with types and boundaries, not function names. A date has calendar meaning, a timestamp is a wall-clock value without a zone, and a timestamptz is an instant displayed through a chosen zone.

For every analysis, pin the reporting zone, interval endpoints, and output grain. Then choose DATE_TRUNC, EXTRACT, interval arithmetic, or a date spine to implement that contract.

Convert instants into the reporting zone first

These event timestamps are stored as timestamptz instants. Converting each instant to a New York wall-clock timestamp before taking the date moves event 1 into February 28 and event 4 into March 31.

Input: events

event_iduser_idevent_tsevent_typeamount
112025-03-01 04:30:00+00purchase20.00
212025-03-01 05:30:00+00purchase30.00
322025-03-15 14:00:00+00clickNULL
422025-04-01 03:30:00+00purchase40.00
532025-04-01 04:30:00+00purchase50.00
SELECT
  event_id,
  (event_ts AT TIME ZONE 'UTC')::date AS utc_date,
  (event_ts AT TIME ZONE 'America/New_York')::date AS new_york_date
FROM events
ORDER BY event_id;
Row flow from UTC instants to reporting dates Five event rows are converted from UTC instants into UTC and New York calendar dates, and two rows cross a date boundary between the zones. 5 instant rowsstored with UTC offsetsConvert wall clocksthen take calendar date5 comparison rows2 dates differ by zone
Casting a timestamptz directly to date would use the session zone, so the reporting zone is named in the query.

Output

event_idutc_datenew_york_date
12025-03-012025-02-28
22025-03-012025-03-01
32025-03-152025-03-15
42025-04-012025-03-31
52025-04-012025-04-01

Fixed offsets are not substitutes for named zones when daylight-saving rules matter. A named zone resolves the applicable offset for each instant.

DATE_TRUNC defines the reporting grain

DATE_TRUNC returns the beginning of a requested unit. Convert to the reporting zone first, then truncate, so events near a UTC boundary land in the intended local month.

Input: events

event_iduser_idevent_tsevent_typeamount
112025-03-01 04:30:00+00purchase20.00
212025-03-01 05:30:00+00purchase30.00
322025-03-15 14:00:00+00clickNULL
422025-04-01 03:30:00+00purchase40.00
532025-04-01 04:30:00+00purchase50.00
SELECT
  DATE_TRUNC(
    'month',
    event_ts AT TIME ZONE 'America/New_York'
  )::date AS month_start,
  COUNT(*) AS event_count,
  SUM(amount) AS purchase_amount
FROM events
GROUP BY month_start
ORDER BY month_start;
Row flow from event instants to local month buckets Five event rows are converted to New York time, truncated into three month keys, and grouped into three monthly output rows. 5 event rowsUTC instants3 local month keystruncate after conversion3 monthly rowscounts and amounts
The NULL click amount contributes to the event count but not to the amount sum.

Output

month_startevent_countpurchase_amount
2025-02-01120.00
2025-03-01370.00
2025-04-01150.00

Use the bucket value as the grouping key and format labels only at the presentation edge. Date or timestamp keys preserve chronological sorting. See SQL GROUP BY for aggregate and NULL behavior.

EXTRACT reads calendar components

EXTRACT returns one component; it does not create a full period key. ISO day-of-week uses Monday as 1 and Sunday as 7. ISO year can differ from calendar year near New Year, as December 31, 2025 demonstrates.

Input: calendar_days

calendar_date
2024-02-29
2025-01-01
2025-12-31
SELECT
  calendar_date,
  EXTRACT(ISODOW FROM calendar_date)::integer AS iso_day_of_week,
  EXTRACT(ISOYEAR FROM calendar_date)::integer AS iso_year,
  DATE_TRUNC('week', calendar_date)::date AS week_start
FROM calendar_days
ORDER BY calendar_date;
Row flow from calendar dates to ISO components Three calendar-date rows are evaluated for ISO weekday, ISO year, and Monday week start, producing three annotated output rows. 3 date rowscalendar valuesRead ISO componentsderive Monday boundary3 annotated rows2025-12-31 is ISO 2026
Pair ISO week numbers with ISO year, not calendar year, when a report uses ISO weeks.

Output

calendar_dateiso_day_of_weekiso_yearweek_start
2024-02-29420242024-02-26
2025-01-01320252024-12-30
2025-12-31320262025-12-29

Interval arithmetic needs explicit units and NULL policy

Subtracting one PostgreSQL date from another returns an integer day count. An open checkout has no return date, so its duration remains NULL rather than being treated as zero or as of today.

Input: checkouts

checkout_idcopy_idcheckout_datereturn_date
112025-01-052025-01-12
212025-02-01NULL
322025-01-202025-02-03
432025-03-102025-03-11
SELECT
  checkout_id,
  checkout_date,
  return_date,
  return_date - checkout_date AS days_borrowed
FROM checkouts
ORDER BY checkout_id;
Row flow through date subtraction Four checkout rows subtract return dates from checkout dates, producing three integer durations and one null duration for the open checkout. 4 checkout rows1 open return datereturn minus checkoutinteger day difference4 output rows7, NULL, 14, 1 days
A NULL duration preserves the distinction between an unfinished checkout and a zero-day checkout.

Output

checkout_idcheckout_datereturn_datedays_borrowed
12025-01-052025-01-127
22025-02-01NULLNULL
32025-01-202025-02-0314
42025-03-102025-03-111

Calendar months are not fixed-day durations. Adding one month can clamp the day to the end of the target month, while adding 30 days follows elapsed calendar days.

Input: date_anchors

anchor_date
2024-02-29
2025-01-31
2025-03-31
SELECT
  anchor_date,
  (anchor_date + INTERVAL '1 month')::date AS plus_one_month,
  (anchor_date + INTERVAL '30 days')::date AS plus_thirty_days
FROM date_anchors
ORDER BY anchor_date;
Row flow through month and day interval addition Three anchor-date rows each receive a one-month result and a 30-day result, revealing different answers for leap day and January month end. 3 anchor rowsmonth-end datesAdd 1 month and 30 daysdifferent units3 comparison rows2 rows differ
Choose a calendar unit for calendar policy and a day count for an elapsed-day policy.

Output

anchor_dateplus_one_monthplus_thirty_days
2024-02-292024-03-292024-03-30
2025-01-312025-02-282025-03-02
2025-03-312025-04-302025-04-30

Use half-open windows and dense period spines

A local March reporting window begins at New York midnight on March 1 and ends just before New York midnight on April 1. Converting those two boundaries to instants gives a complete, non-overlapping filter for timestamptz events.

Input: report_window

time_zonestart_dateend_date
America/New_York2025-03-012025-04-01

Input: events

event_iduser_idevent_tsevent_typeamount
112025-03-01 04:30:00+00purchase20.00
212025-03-01 05:30:00+00purchase30.00
322025-03-15 14:00:00+00clickNULL
422025-04-01 03:30:00+00purchase40.00
532025-04-01 04:30:00+00purchase50.00
SELECT
  e.event_id,
  e.event_ts AT TIME ZONE r.time_zone AS local_event_ts
FROM events AS e
CROSS JOIN report_window AS r
WHERE e.event_ts >= r.start_date::timestamp AT TIME ZONE r.time_zone
  AND e.event_ts <  r.end_date::timestamp AT TIME ZONE r.time_zone
ORDER BY e.event_ts, e.event_id;
Row flow through a half-open local-month window Five UTC event rows are tested against New York March boundaries converted to instants, and three rows inside the local month remain. 5 instant rowsaround month edgeslocal start ≤ instant < endzone-aware boundaries3 March rowsevents 2, 3, and 4
Event 1 is February 28 locally; event 5 is exactly the first half hour of local April.

Output

event_idlocal_event_ts
22025-03-01 00:30:00
32025-03-15 10:00:00
42025-03-31 23:30:00

The same half-open idea applies to dates and timestamps without zones; SQL BETWEEN compares the boundary choices directly.

Window frames count rows unless the ordering frame says otherwise. Build a dense weekly spine first so a four-row frame represents four consecutive report weeks, including the quiet week of January 20.

Input: report_weeks

start_weekend_week
2025-01-062025-02-03

Input: transactions

transaction_iduser_idtransaction_tsamount
112025-01-06 10:00:0020.00
212025-01-09 11:00:0030.00
312025-01-14 09:00:0050.00
412025-01-27 09:00:0040.00
512025-02-03 09:00:0010.00
WITH spine AS (
  SELECT
    GENERATE_SERIES(
      start_week,
      end_week,
      INTERVAL '7 days'
    )::date AS week_start
  FROM report_weeks
),
weekly AS (
  SELECT
    DATE_TRUNC('week', transaction_ts)::date AS week_start,
    SUM(amount) AS weekly_revenue
  FROM transactions
  GROUP BY week_start
),
dense AS (
  SELECT
    s.week_start,
    COALESCE(w.weekly_revenue, 0)::numeric(12,2) AS weekly_revenue
  FROM spine AS s
  LEFT JOIN weekly AS w
    ON w.week_start = s.week_start
)
SELECT
  week_start,
  weekly_revenue,
  SUM(weekly_revenue) OVER (
    ORDER BY week_start
    ROWS BETWEEN 3 PRECEDING AND CURRENT ROW
  ) AS rolling_4_week_revenue
FROM dense
ORDER BY week_start;
Row flow from sparse transactions to a dense rolling window Five transaction rows aggregate onto four present weeks, a spine adds the missing week, and a four-row frame returns five rolling weekly rows. 5 transaction rows4 present weeks5 dense weeksJanuary 20 = 05 rolling rowsfour true report weeks
At February 3, the four-row frame now covers January 13, January 20, January 27, and February 3.

Output

week_startweekly_revenuerolling_4_week_revenue
2025-01-0650.0050.00
2025-01-1350.00100.00
2025-01-200.00100.00
2025-01-2740.00140.00
2025-02-0310.00100.00

The SQL CTE guide shows the same spine idea with recursion. For run detection rather than fixed windows, use SQL gaps and islands; window-frame mechanics are covered in the window-functions guide.

FAQ

What does DATE_TRUNC return?

It returns the input value reduced to the start of the requested unit, with type behavior determined by the input. For reporting on timestamptz, decide the reporting zone before forming local calendar buckets.

What is the difference between EXTRACT(DOW) and EXTRACT(ISODOW)?

In PostgreSQL, DOW numbers Sunday as 0 through Saturday as 6. ISODOW numbers Monday as 1 through Sunday as 7. Match the function to the calendar definition.

Why use a half-open date or timestamp range?

An inclusive start and exclusive next boundary covers every instant in the intended period without inventing a final timestamp. Adjacent periods can share the same boundary without overlap.

Is one month the same as 30 days?

No. A calendar month varies in length and month-end addition can clamp the day. Use the unit named by the business rule.

Why build a date spine?

Source tables usually omit periods with no activity. A spine supplies those report keys so zeroes become explicit and row-based windows operate over consecutive periods.


Comments (0)