Dates, times and intervals

SQL · Practical · 11 min

Nearly every report is 'per day', 'last 30 days' or 'per month', and time zone bugs are subtle.

PostgreSQL's date and time support is rich and mostly standard. The building blocks:

  • Current time: now() (the transaction's start time, as timestamptz), current_date, clock_timestamp() (wall clock, changes during a transaction).
  • Intervals: interval '30 days', interval '2 hours'. Add them to dates and timestamps: now() - interval '30 days'. Subtracting two timestamps gives an interval; subtracting two dates gives an integer number of days.
  • Truncation: date_trunc('month', ordered_at) rounds down to the start of the month. It is the workhorse of time-bucketed reports.
  • Extraction: extract(year FROM ordered_at), extract(dow FROM ...) (day of week, Sunday = 0), extract(epoch FROM ...) (seconds since 1970).
  • Formatting: to_char(ordered_at, 'YYYY-MM-DD'); parsing with to_date and to_timestamp.

Time zones deserve care. A timestamptz stores an absolute instant (internally UTC) and is displayed in the session's TimeZone setting. AT TIME ZONE 'Asia/Yerevan' converts it to that zone's local wall time. "Per day" is therefore ambiguous: whose day? Truncate in the business's zone, date_trunc('day', ordered_at AT TIME ZONE 'Europe/London'), or users near midnight will be counted on the wrong day.

generate_series(start, stop, interval) produces a row per step. Left-joining it to your data fills in the days with no activity, so charts show zeros instead of gaps.

Dialects differ heavily here: SQL Server uses DATEADD, DATEDIFF and GETDATE(); MySQL uses DATE_ADD(d, INTERVAL 30 DAY) and DATE_FORMAT. The concepts carry over, the spelling does not.

Example

SELECT now() - interval '30 days'                AS a_month_ago,
       date '2026-03-15' - date '2026-01-05'     AS days_between,  -- 69
       extract(dow FROM date '2026-03-15')       AS weekday;       -- 0 (Sunday)

-- Revenue per month in the shop's time zone
SELECT date_trunc('month', ordered_at AT TIME ZONE 'Europe/London') AS month,
       SUM(total) AS revenue
FROM orders WHERE status <> 'cancelled'
GROUP BY 1 ORDER BY 1;
--  2026-01-01 00:00:00 |  68.50
--  2026-02-01 00:00:00 | 198.99
--  2026-03-01 00:00:00 | 238.00

-- Every day of the week, including days without orders
SELECT d::date AS day, COUNT(o.id) AS orders
FROM generate_series(date '2026-02-09', date '2026-02-15', interval '1 day') AS d
LEFT JOIN orders o ON o.ordered_at::date = d::date
GROUP BY d ORDER BY d;
--  2026-02-09 | 0
--  2026-02-10 | 1
--  2026-02-11 | 1
--  2026-02-12 | 0   ...

Remember

  • Store instants as timestamptz; convert with AT TIME ZONE for local-day logic
  • date_trunc buckets timestamps; extract pulls out parts
  • Timestamp arithmetic uses intervals; date minus date gives days
  • generate_series + LEFT JOIN fills gaps in time series

Watch out for

  • Grouping by UTC day for a business whose 'day' is in another time zone.

Check yourself

  1. What does date_trunc('month', timestamp '2026-02-11 14:30') return?
  2. Why might per-day counts differ between a London report and a Yerevan report on the same data?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/functions-datetime.html

Builds on

Unlocks

Nothing builds on this yet.