time
Should You Store Timestamps in UTC? Yes — Except Future Local-Time Events
Why "store UTC, display local" is the right default for recording instants, and the exception that corrupts calendars: future events defined in local time need a wall time plus an IANA zone name, because governments really do rewrite timezone rules with weeks of notice.
“Store timestamps in UTC, convert to local time for display” is genuinely good advice, and you should follow it — for most of your data. But it hides a trap that almost every calendar, booking, and scheduling system falls into once: “store UTC” is a rule about recording the past, and applying it to future events defined in local time silently stores the wrong answer. The difference isn’t pedantry. It’s the gap between a timestamp that is the fact, and a timestamp that is merely a prediction about what governments will do with their clocks.
Here’s the default, the exception, and the storage format that handles both.
Why UTC is right for the past
When something has happened — a login, a payment, a log line, a sensor reading — the thing you’re recording is an instant: a single point on the global timeline. Instants are what UTC (or equivalently, a Unix timestamp, which is a count of seconds since a UTC instant) represents perfectly:
- Unambiguous.
2026-08-19T14:00:00Zis one moment, everywhere. A bare local time like “2:00 AM on November 1” can name two different moments — or none — depending on the zone and the DST calendar. - Sortable and subtractable. UTC instants compare with plain integer math. No offsets, no calendar rules, no “was DST in effect?” lookups in your
ORDER BY. - No DST holes or overlaps. Local wall clocks skip an hour in spring and repeat one in autumn. UTC never does, so a UTC event log can’t contain two events “at the same time” that were actually an hour apart.
For past instants, converting to UTC at write time loses nothing, because the conversion is settled history: the offset that was in effect is a fact that can never change. Store the instant, and render it in each viewer’s zone at display time.
The exception: a shop that opens at 9:00 in Berlin
Now schedule something in the future that’s defined by a wall clock, not by an instant: a shop opens at 9:00 AM in Berlin on March 15, 2027.
Follow the “store UTC” rule naively. Today, your code looks up Europe/Berlin, sees that March 15 falls before the late-March DST switch, applies the winter offset of +01:00, and stores:
2027-03-15T08:00:00Z
That stored value is not the fact “the shop opens at 9:00 Berlin time.” It’s a derived prediction: “9:00 in Berlin will convert to 08:00 UTC, assuming Germany’s timezone rules on that day are what today’s timezone database says they’ll be.” If that assumption breaks — Germany adopts permanent summer time (the EU has been debating exactly this for years), or shifts its DST dates — your database still faithfully says 08:00Z, and every display of it now reads 10:00 AM local. The shop’s own website tells customers the wrong opening hour, off by exactly the amount of the rule change, for every future event you converted early.
| What you stored | What it means | What breaks it |
|---|---|---|
2027-03-15T08:00:00Z (converted at write) | “The instant that currently corresponds to 9:00 Berlin” | Any change to Berlin’s offset rules before the event |
2027-03-15T09:00 + Europe/Berlin | ”9:00 on the Berlin wall clock, whatever that turns out to mean” | Nothing — it’s the actual requirement |
The past-event version of this problem doesn’t exist: rules can’t retroactively change what offset was in effect. The future-event version is a live risk, because timezone rules are political decisions, not physics.
Governments really do this — with 17 days’ notice
This is the part people wave off as theoretical. It isn’t. The IANA time zone database (tzdata) ships multiple releases a year precisely because jurisdictions keep changing their rules, sometimes on startlingly short notice.
A concrete one: on October 11, 2022, the tzdata 2022e release announced that “Jordan and Syria are abandoning the DST regime and are changing to permanent +03, so they will not fall back from +03 to +02 on 2022-10-28.” The change took effect October 28, 2022 — seventeen days after the announcement.
Play that against a calendar app that converted early. A 9:00 AM Amman meeting in December 2022, saved that summer, would have been stored as 07:00Z (using the +02 winter offset everyone expected). After the decree, Jordan’s December offset was +03, so 07:00Z renders as 10:00 AM local — every future Jordanian event in the database now displays an hour late, and no code changed. The data was wrong the moment the government spoke.
Jordan isn’t an outlier; recent tzdata history includes similar short-notice changes in multiple countries. Every one of them rewrites the future half of any “converted at write time” table.
What to store instead: wall time + IANA zone name
For future events defined in local time, store exactly what the user told you, and resolve it to an instant as late as possible — at query time, at notification-scheduling time, at display time — always against the current timezone database:
{
"opens_at": "2027-03-15T09:00",
"time_zone": "Europe/Berlin"
}
There’s now a standard serialization for this. RFC 9557 (the Internet Extended Date/Time Format, IXDTF) extends RFC 3339 with a bracketed suffix for the IANA zone name:
2027-03-15T09:00:00+01:00[Europe/Berlin]
The offset is still allowed in the string, but the zone name is the authority — RFC 9557 even defines a critical flag ([!Europe/Berlin]) meaning “do not act on this timestamp unless you can process the zone suffix.” JavaScript’s Temporal API parses and emits this format natively.
Two traps to avoid while doing this:
- Never store an offset instead of a zone.
+01:00is a frozen fact about one moment; it knows nothing about DST or future rule changes.Europe/Berlinis a ruleset — it’s +01:00 in January and +02:00 in July, and it updates when tzdata does. An offset answers “what was it then?”; only a zone name can answer “what will it be?” America/New_Yorkis not “EST”. Abbreviations like EST, CST, and IST are ambiguous (IST is India, Ireland, and Israel) and usually denote a fixed offset — many date libraries parseESTas a hard −05:00 year-round. New York is EST for four months and EDT for eight. Store the IANA name; treat abbreviations as display-only decoration.
”9:00 AM daily” on the days when 9:00 doesn’t exist
Once you store wall times, you inherit the wall clock’s two annual glitches, and for recurring events you must decide what they mean:
- The skipped hour. In
Europe/Berlin, clocks jump from 02:00 to 03:00 on spring-forward day —02:30on March 28, 2027 simply does not exist. - The repeated hour. On fall-back day,
02:30happens twice, an hour apart.
What “2:30 AM daily” means on those two days is a product decision, not something a library can guess for you. The Temporal API makes the options explicit — resolving a wall time to an instant takes a disambiguation option with four values:
| Option | Skipped time (gap) | Repeated time (ambiguity) |
|---|---|---|
'compatible' (default) | Move forward by the gap (02:30 → 03:30) | Take the earlier instant |
'earlier' | Go back by the gap | Take the earlier instant |
'later' | Go forward by the gap | Take the later instant |
'reject' | Throw a RangeError | Throw a RangeError |
Temporal.ZonedDateTime.from('2027-03-28T02:30[Europe/Berlin]', {
disambiguation: 'reject',
}); // RangeError — this wall time never happens
'compatible' mirrors what legacy Date does and is a sane default; 'reject' is the right choice when a nonexistent time indicates bad input rather than a schedule crossing DST. One caveat before you ship this: MDN currently lists Temporal as limited availability — it’s not Baseline and doesn’t yet work in some widely-used browsers, so you’ll want a polyfill on the front end for now.
The cron corollary
Cron is the place this whole distinction bites operations teams. A crontab line has no timezone field — 0 9 * * * means 9:00 in whatever zone the scheduler runs in. On the typical UTC server, that job fires at 09:00 UTC year-round, which for your New York users is 4:00 AM in winter and 5:00 AM in summer. The job never moves; the users do, twice a year. If “9 AM for the user” is the requirement, a fixed-zone cron is the offset-storage mistake wearing a different hat, and you need a scheduler that accepts a zone (or a shim that re-resolves the wall time daily).
Before deploying any schedule, paste the expression into our cron parser — it translates the five fields into plain English and lists the next 10 execution times in your browser’s local timezone, which makes a UTC-vs-local mismatch visible immediately instead of at 4 AM. (Building the expression from scratch? The crontab builder goes the other direction.)
The Postgres footnote: timestamptz does not store a timezone
One misconception deserves its own callout, because the type name actively encourages it. PostgreSQL’s timestamp with time zone (timestamptz) does not store a timezone. Per the Postgres docs, the value “is stored internally as UTC, and the originally stated or assumed time zone is not retained” — the zone in your input is used once, to convert to UTC on the way in, and output is converted to the session’s timezone setting on the way out.
That makes timestamptz exactly the right type for instants — past events, created_at, log lines — and exactly the wrong sole column for future local-time events. For those, the standard pattern is two columns: a timestamp (without time zone) holding the wall time, plus a text column holding the IANA zone name, resolved to an instant in application code as late as possible.
The short version
| You’re storing… | Store | Because |
|---|---|---|
A past event / instant (created_at, logs, payments) | UTC (or a Unix timestamp) | The conversion is settled history; sorts and subtracts cleanly |
| A future event at a fixed instant (rocket launch, contract expiry in UTC) | UTC | The requirement is an instant |
| A future event at a local wall time (meeting, shop hours, 9 AM reminder) | Wall time + IANA zone name (2027-03-15T09:00 + Europe/Berlin) | Rules change; resolve to an instant as late as possible |
| A recurrence (“daily at 9”) | Wall time + zone + an explicit skipped/repeated-hour policy | What DST days mean is a product decision |
And when you’re reasoning about a specific conversion, do it with live data instead of mental arithmetic: our timezone converter takes one date, time, and source IANA zone and shows that instant across a whole table of zones with their UTC offsets — add any of the browser’s supported IANA zones to the list — which is the fastest way to see a DST offset shift with your own eyes (try the same Berlin time a week before and after March 28). For raw epoch values, the timestamp converter converts both directions, auto-detects seconds vs. milliseconds, and shows local, ISO 8601, and relative time. Both run entirely in your browser.
Store UTC. Display local. And for anything in the future that’s pinned to a wall clock, store the wall clock — the instant doesn’t exist yet.