13 KiB
Dates, Times, and Timezones
Every timestamp bug traces to one of three questions never answered: what instant is stored, in whose calendar is it interpreted, and where is the boundary of "a day". Answer all three in the schema and most of these disappear.
Contents: Store UTC · Type Selection · The Wall-Clock Exception · Conversion · Day Boundaries · Truncation and Grouping · DST · Intervals and Age · Ranges · Business Days · Fiscal and ISO Weeks · Comparing and Indexing · Dialect Table · Traps
Store the Instant in UTC
The default with no exceptions worth taking casually: store an absolute instant in UTC, convert at the edges, and never store a local time without also storing which zone it means.
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- PostgreSQL: UTC instant
);
An offset is not a timezone. -05:00 tells you nothing about what that clock reads next November; America/New_York does. Store the IANA zone name when the zone matters, never the offset.
Type Selection
| Need | PostgreSQL | MySQL | SQLite | SQL Server |
|---|---|---|---|---|
| Absolute instant | TIMESTAMPTZ |
TIMESTAMP (UTC internally, converted by session zone; range ends 2038) or DATETIME holding UTC |
TEXT ISO-8601 UTC, or INTEGER epoch |
DATETIMEOFFSET |
| Naive local wall clock | TIMESTAMP |
DATETIME |
TEXT |
DATETIME2 |
| Calendar date only | DATE |
DATE |
TEXT YYYY-MM-DD |
DATE |
| Time of day only | TIME |
TIME |
TEXT |
TIME |
| Duration | INTERVAL |
integer seconds | integer seconds | integer seconds |
- PostgreSQL
TIMESTAMPTZdoes not store a zone. It converts the input to UTC on write and renders it in the session'sTimeZoneon read. The name misleads everyone once. - MySQL
TIMESTAMPconverts on both write and read using the session timezone and tops out in 2038.DATETIMEstores exactly what you gave it with no conversion — which is why MySQL projects usually standardize onDATETIMEholding UTC plus an application rule. - SQLite has no date type; text in strict
YYYY-MM-DD HH:MM:SSsorts and compares correctly, epoch integers are compact. Pick one per database and never mix. - Durations as
INTERVALare convenient but not portable and awkward to aggregate — integer seconds (or milliseconds, named in the column:duration_ms) travel everywhere.
The Wall-Clock Exception
Some events are defined by local time, not by an instant. A 09:00 recurring meeting in Berlin stays at 09:00 through a DST change; the UTC instant moves.
Store both parts:
CREATE TABLE appointments (
starts_at_local TIMESTAMP NOT NULL, -- naive wall clock
tz TEXT NOT NULL, -- IANA name, e.g. 'Europe/Berlin'
starts_at_utc TIMESTAMPTZ -- resolved instant, recomputed if rules change
GENERATED ALWAYS AS (starts_at_local AT TIME ZONE tz) STORED
);
The generated column gives you an indexable instant for "what is next" queries while the local pair remains the source of truth. Governments change timezone rules with months of notice — the resolved instants must be recomputable, which is exactly why the local time is stored.
Birthdays, contract dates, and holidays are DATE, not timestamps. Storing a birthday as a timestamp makes it shift a day for some users forever.
Conversion
-- PostgreSQL: AT TIME ZONE flips meaning based on the input type
SELECT occurred_at AT TIME ZONE 'America/New_York'; -- timestamptz → naive local time
SELECT local_ts AT TIME ZONE 'America/New_York'; -- naive → timestamptz (instant)
-- Session default for rendering
SET TIME ZONE 'UTC';
-- MySQL (requires the timezone tables to be loaded, or only offsets work)
SELECT CONVERT_TZ(created_at, '+00:00', 'America/New_York');
-- SQLite
SELECT datetime(occurred_at, 'localtime'); -- server's zone, not the user's
-- SQL Server
SELECT created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Eastern Standard Time';
MySQL's CONVERT_TZ returns NULL when the named zone is unknown — a fresh server without mysql_tzinfo_to_sql loaded turns every converted timestamp into NULL. Test with a named zone, never with an offset, or the failure hides until production.
Day Boundaries
"Today" is a range in a specific zone, and the range is what the query must express:
-- Today in the user's zone, expressed as an instant range (index-usable)
WHERE occurred_at >= (DATE '2026-03-15')::timestamp AT TIME ZONE :user_tz
AND occurred_at < (DATE '2026-03-16')::timestamp AT TIME ZONE :user_tz
- Never
WHERE DATE(occurred_at) = '2026-03-15': it applies a function to the column (no index) and uses the session zone, so the same query returns different rows for different connections. - A daily report and a dashboard that disagree by a few rows are almost always splitting the day at different zones.
- Pick one reporting zone per tenant or per report and record it with the metric. "UTC days" is a legitimate choice; an unstated choice is not.
Truncation and Grouping
DATE_TRUNC('month', occurred_at) -- PostgreSQL, also 'week','quarter'
DATE_TRUNC('day', occurred_at AT TIME ZONE 'Europe/Madrid') -- truncate in the reporting zone
DATE_FORMAT(created_at, '%Y-%m-01') -- MySQL month bucket
strftime('%Y-%m', occurred_at) -- SQLite
DATETRUNC(month, created_at) -- SQL Server 2022+; earlier: EOMONTH/DATEADD
DATE_TRUNC('week', ...) starts on Monday in PostgreSQL (ISO). MySQL's WEEK() defaults to Sunday and takes a mode argument controlling both the first day and how the first week of the year is counted. A "weekly" chart that disagrees between two systems is nearly always this.
Truncate in the reporting zone, then group — grouping UTC-truncated days and relabelling them locally is off by the offset for part of every day.
Daylight Saving Time
- Some local times do not exist (the spring-forward gap) and some occur twice (the autumn repeat). Converting a naive local time to an instant is therefore ambiguous or invalid for two hours a year; engines resolve it by rule, not by asking. Store the intended instant for anything that must not shift.
- A "day" is not always 24 hours: adding
INTERVAL '1 day'in PostgreSQL adds a calendar day (23 or 25 hours across a transition), while addingINTERVAL '24 hours'adds exactly 24. Pick according to whether you mean "same time tomorrow" or "one day later". - Scheduled jobs at 02:30 local never run on the spring-forward day and run twice on the autumn day. Schedule recurring jobs in UTC.
- Durations computed by subtracting local times cross transitions incorrectly. Subtract instants.
- Zone rules change: countries abolish DST, shift offsets, and split zones. The tzdata package must be updated on database servers too, not only on application hosts.
Intervals and Age
-- Difference as an interval, and as a number
SELECT ended_at - started_at AS duration; -- PostgreSQL interval
SELECT EXTRACT(EPOCH FROM (ended_at - started_at)) AS seconds; -- PostgreSQL
SELECT TIMESTAMPDIFF(SECOND, started_at, ended_at); -- MySQL
SELECT (julianday(ended_at) - julianday(started_at)) * 86400.0; -- SQLite
SELECT DATEDIFF(second, started_at, ended_at); -- SQL Server
- SQL Server's
DATEDIFF(year, a, b)counts boundary crossings, not elapsed years: 2025-12-31 to 2026-01-01 is 1 year. MySQL'sTIMESTAMPDIFFcounts complete units. They disagree by design. - Age in whole years is not
days / 365.25: leap years make that wrong for some birthdays. Use the engine's age function, or compare the month-day pair explicitly. - "Months between" is genuinely ambiguous (Jan 31 plus one month). Adding a month clamps to the last valid day in most engines, so
Jan 31 + 1 month - 1 monthdoes not return Jan 31. Never round-trip through month arithmetic. - Intervals are not additive with fixed conversions:
INTERVAL '1 month'has no fixed number of days until anchored to a date.
Ranges and Overlap
-- Portable overlap test between [a_start, a_end) and [b_start, b_end)
WHERE a_start < b_end AND a_end > b_start
-- PostgreSQL range types handle inclusivity explicitly
WHERE tstzrange(start_at, end_at, '[)') && tstzrange(:from, :to, '[)')
-- Prevent double booking in the database rather than in code
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, tstzrange(start_at, end_at, '[)') WITH &&);
Use half-open [) ranges everywhere: they tile without gaps or overlaps, so back-to-back bookings do not collide and BETWEEN's inclusive upper bound never bites (SKILL.md Traps). Hand-written overlap conditions typically miss the containment case; the two-comparison form above covers all four.
Business Days and Holidays
Business-day arithmetic in pure SQL is either wrong or unreadable. Build a calendar table once:
CREATE TABLE calendar (
day DATE PRIMARY KEY,
is_business_day BOOLEAN NOT NULL,
iso_week INT NOT NULL,
month_start DATE NOT NULL,
fiscal_year INT NOT NULL,
fiscal_quarter INT NOT NULL
);
-- "5 business days after 2026-03-10"
SELECT day FROM calendar WHERE is_business_day AND day > DATE '2026-03-10'
ORDER BY day OFFSET 4 LIMIT 1;
Holidays are jurisdictional and change yearly; a calendar table is data you maintain, not logic you derive. It also doubles as the date spine for reporting and the date dimension of a star schema.
Fiscal Calendars and ISO Weeks
- ISO 8601: weeks start Monday, and week 1 is the week containing the first Thursday. Consequence: the first days of January can belong to week 52/53 of the previous ISO year. Always pair
EXTRACT(WEEK ...)withEXTRACT(ISOYEAR ...)— using the calendar year with the ISO week misplaces early-January rows by a full year, every year. - Some years have 53 ISO weeks. Any "week over week, 52 rows" assumption breaks in those years.
- Fiscal years rarely start in January, and retail 4-4-5 calendars are not derivable from dates at all. Put them in the calendar table.
- Quarter boundaries differ between fiscal and calendar quarters; label which one a chart shows.
Comparing and Indexing Timestamps
- Compare instants to instants. A
TIMESTAMPTZcompared against a naive string literal is interpreted in the session zone — the same query then behaves differently for two connections. - Half-open ranges keep the index usable and avoid boundary loss; functions on the column disable the index entirely.
- Index the raw column and range-scan it, rather than indexing
DATE(col)— the raw index serves both day queries and arbitrary ranges. - For very large append-only tables ordered by time, partition by range on the timestamp and confirm every query filters on the partition key, or BRIN-index it.
- Beware
NOW()inside a transaction: PostgreSQL'sNOW()/CURRENT_TIMESTAMPis frozen at transaction start, so every row inserted in a long transaction shares one timestamp.clock_timestamp()gives real wall time.
Dialect Quick Table
| Operation | PostgreSQL | MySQL | SQLite | SQL Server |
|---|---|---|---|---|
| Now (instant) | NOW() / CURRENT_TIMESTAMP |
NOW() / UTC_TIMESTAMP() |
datetime('now') (UTC) |
SYSUTCDATETIME() |
| Add 1 day | d + INTERVAL '1 day' |
DATE_ADD(d, INTERVAL 1 DAY) |
datetime(d,'+1 day') |
DATEADD(day,1,d) |
| Difference | b - a (interval) |
TIMESTAMPDIFF(unit,a,b) |
julianday(b)-julianday(a) |
DATEDIFF(unit,a,b) |
| Truncate to month | DATE_TRUNC('month',d) |
DATE_FORMAT(d,'%Y-%m-01') |
date(d,'start of month') |
DATETRUNC(month,d) (2022+) |
| Extract part | EXTRACT(YEAR FROM d) |
YEAR(d) |
strftime('%Y',d) |
DATEPART(year,d) |
| Format | TO_CHAR(d,'YYYY-MM-DD') |
DATE_FORMAT(d,'%Y-%m-%d') |
strftime('%Y-%m-%d',d) |
FORMAT(d,'yyyy-MM-dd') |
| Parse | TO_TIMESTAMP(s,'...') |
STR_TO_DATE(s,'...') |
datetime(s) |
TRY_CONVERT/PARSE |
| Week start | Monday (ISO) | Mode-dependent, Sunday default | strftime('%W'), Monday |
DATEFIRST-dependent |
Traps
| Trap | Why it fails | Do instead |
|---|---|---|
WHERE DATE(ts) = :day |
Not sargable, and uses the session zone | Instant range in the chosen reporting zone |
| Storing a UTC offset instead of a zone name | Offsets do not survive DST | Store the IANA zone name |
| Storing a birthday as a timestamp | Shifts a day for users in other zones | DATE |
days / 365.25 for age in years |
Wrong across leap years for some dates | Engine age function, or month-day comparison |
INTERVAL '24 hours' for "next day" |
Off by an hour across DST transitions | INTERVAL '1 day' when you mean the calendar day |
| Calendar year with ISO week number | Early-January rows land in the wrong year | Pair ISOYEAR with WEEK |
| Cron at 02:30 local | Skipped or duplicated on transition days | Schedule in UTC |
Mixing TIMESTAMP and TIMESTAMPTZ columns in one schema |
Every comparison reinterprets one of them without warning | One convention per database, enforced in review |
Relying on the server's localtime |
Server zone is infrastructure, not a user preference | Pass the user's zone explicitly |