205 lines
13 KiB
Markdown
205 lines
13 KiB
Markdown
# 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.
|
|
|
|
```sql
|
|
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 `TIMESTAMPTZ` does **not** store a zone. It converts the input to UTC on write and renders it in the session's `TimeZone` on read. The name misleads everyone once.
|
|
- MySQL `TIMESTAMP` converts on both write and read using the session timezone and tops out in 2038. `DATETIME` stores exactly what you gave it with no conversion — which is why MySQL projects usually standardize on `DATETIME` holding UTC plus an application rule.
|
|
- SQLite has no date type; text in strict `YYYY-MM-DD HH:MM:SS` sorts and compares correctly, epoch integers are compact. Pick one per database and never mix.
|
|
- Durations as `INTERVAL` are 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:
|
|
|
|
```sql
|
|
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
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
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 adding `INTERVAL '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
|
|
|
|
```sql
|
|
-- 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's `TIMESTAMPDIFF` counts 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 month` does 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
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
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 ...)` with `EXTRACT(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 `TIMESTAMPTZ` compared 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's `NOW()`/`CURRENT_TIMESTAMP` is 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 |
|