The rule, and the wrong half of it
The advice everybody gives is: store UTC, convert at the edges. For a log line, a payment, an audit entry, a created_at, that is exactly right. Those are instants. They happened at one point on the timeline, every observer on earth agrees on which point, and UTC names it without ambiguity. Store the instant, render it in whatever zone the reader wants.
Then somebody books a meeting for 9:00 in Vienna in March 2028, your code converts it to 2028-03-15T08:00:00Z, and the data is now subtly broken. Not wrong today. Wrong the moment Austria, or the EU, changes its DST rules, because your row still says 08:00 UTC and 08:00 UTC is no longer 9:00 in Vienna.
The distinction that makes the whole topic tractable: an instant is a point on the timeline, a local date-time is what a wall clock shows. Converting between them needs a rule set, and rule sets are politics, not physics. Past events already have their conversion baked in, so UTC is safe. Future local events do not, so UTC is a guess.
A future event is not an instant yet
When a user books "9:00, Vienna, 15 March 2028", they are not making a statement about UTC. They are saying: whatever the clock on the wall in Vienna reads 9:00 that morning, be there. If the rules change between now and then, they still mean 9:00 on the wall. Nobody has ever wanted their recurring standup to shift by an hour because a parliament passed a law.
And that is a live scenario in Europe. The European Parliament voted in March 2019 to end seasonal clock changes, with 2021 as the target date. The Council of the EU never agreed a position on the file, so nothing happened, and the twice-yearly change is still in effect in 2026 while Spain pushes to revive the proposal. If it ever passes, every future local timestamp stored as UTC across 27 member states is off by an hour in one direction or the other. One legislative vote, one silent data migration nobody planned.
So: future local events get stored as a local date-time plus an IANA zone name (Europe/Vienna), and the instant is computed when you need it, against whatever tzdb version is current at that moment. If you also need a UTC value for indexing or for a scheduler, treat it as a derived cache and be prepared to recompute it after a tzdb update. Which is the same discipline you need one layer down in the scheduler itself, see our notes on cron, DST and jobs that never run.
An offset is not a timezone
This is the most common confusion in the whole area, and it hides inside data formats that look precise. +02:00 is a UTC offset. Europe/Vienna is a timezone. The offset is the output of applying the timezone’s rules to one specific instant; it carries no information about any other instant.
| Value | What it is | What it survives |
|---|---|---|
+02:00 | An offset at one moment | Nothing. It cannot tell you next winter’s offset. |
CEST | An abbreviation, not standardised | Nothing. CST alone means US Central, China Standard and Cuba Standard time. |
Europe/Vienna | A rule set with history and future rules | Rule changes, once you update the tzdb. |
An RFC 3339 timestamp such as 2028-03-15T09:00:00+01:00 looks complete and is not, for the future case: it pins the instant but throws away which zone produced the offset, so no future correction is possible. RFC 9557, published in 2024, fixes precisely this by extending the syntax with a bracketed zone name: 2028-03-15T09:00:00+01:00[Europe/Vienna]. It is what the JavaScript Temporal API emits, and if you are designing an API today it is the format to accept.
The tzdb ships all year, and so should you
The IANA time zone database is the shared source of truth for every operating system, language runtime and database, and it changes whenever a government decides something. Not on a schedule. In 2023 there were four releases; the most recent one at the time of writing is 2026a from April 2026.
Two examples from a single month in 2023 that show what "with notice" means in practice:
- Egypt announced in March 2023 that it was reintroducing DST after years without it, effective from the last Friday of April, 28 April 2023. That is roughly two months of warning for every calendar system on the planet, and it landed in tzdb 2023a.
- Lebanon did it with two days. On 23 March 2023 the government postponed the DST start from 25 March to 20 April, mid-Ramadan, and tzdb 2023b shipped the change the same day. The country then split: some institutions, broadcasters and phone platforms followed the decree, others refused, and Lebanon ran on two different local times simultaneously for several days. The decision was reversed on 27 March, and tzdb 2023c on 28 March put the data back the way it was.
The operational lesson is not "keep your servers patched", it is that your stack contains several independent copies of the tzdb: the OS in /usr/share/zoneinfo, the JVM’s bundled copy, ICU, every browser, and Postgres, which ships its own. They update on different release cycles. Two services can convert the same future local time to different instants and both be internally consistent. If a conversion result matters legally or financially, store the tzdb version alongside it.
What timestamptz really stores
Postgres has timestamp and timestamptz, and the second one does not store a timezone. This surprises nearly everyone, including people who have used it for years.
Both types are 8 bytes, held as microseconds relative to the Postgres epoch of 2000-01-01. There is no room in there for a zone and none is kept. What timestamptz does is convert: on write it interprets the input in the session TimeZone and normalises to UTC, on read it renders back into the session TimeZone. Read it as "timestamp, normalised to UTC". timestamp without a zone does no conversion at all and stores the wall-clock value verbatim.
| Case | Postgres |
|---|---|
| Something that happened | timestamptz |
| Future local appointment | timestamp plus a text column with the IANA zone name |
| Birthday, invoice date, contract date | date, no time, no zone |
Two more Postgres details worth having in your head. AT TIME ZONE flips the type rather than adjusting a value: applied to a timestamptz it produces a timestamp in that zone, applied to a timestamp it produces a timestamptz. And because conversion happens against the session setting, a connection pool where one client sets TimeZone and the next one inherits it produces bugs that only appear under load. Set it explicitly per session or leave it at UTC everywhere.
MySQL splits the same distinction differently: TIMESTAMP converts to UTC like Postgres does, DATETIME does not, and TIMESTAMP still carries the 32-bit range limit that ends on 19 January 2038. For anything with a date beyond that, which includes a lot of contracts and pension records, DATETIME or a 64-bit column is the only option.
Unix time pretends leap seconds do not exist
POSIX defines Unix time as a formula: days since 1970 times 86400, plus seconds in the day. Exactly 86400 seconds per day, by definition. Since UTC actually inserts leap seconds to stay aligned with the earth’s rotation, the two cannot both be right, and the formula wins: a Unix timestamp has no way to express 23:59:60, so around a leap second it either repeats a value or the second gets absorbed by whatever your NTP client does.
27 leap seconds have been inserted since 1972, the most recent at the end of 2016. Google and AWS both avoid the discontinuity by smearing, spreading the extra second across a 24-hour window so their clocks tick slightly slow instead of jumping. Correct, and it means a smeared machine and an unsmeared machine disagree by up to half a second on those days, which is plenty to reorder events in a distributed log.
There is an end date, at least: the CGPM voted in November 2022 to stop inserting leap seconds by 2035. Until then, and honestly afterwards too, the practical rules are the same ones you want anyway. Never measure elapsed time with wall-clock timestamps, use a monotonic clock (CLOCK_MONOTONIC, performance.now(), System.nanoTime()); never assume timestamps from two machines are ordered correctly; never treat second-level equality as identity. Sorting by a timestamp column is a heuristic, not a guarantee. If you need identifiers that sort by creation time, that is a job for the ID, and we compared the options in UUIDv4 vs UUIDv7, where v7 embeds a millisecond Unix timestamp and inherits every clock problem above.
JavaScript Date, and why Temporal exists
The Date object was written in ten days in 1995 as a copy of java.util.Date, an API Java itself deprecated most of in 1997. What survived into every browser: zero-indexed months, mutable objects, no timezone support beyond the host zone and UTC, and parsing rules that differ by string shape. That last one is the one that ships bugs:
| Expression | Interpreted as |
|---|---|
new Date('2026-07-31') | UTC midnight |
new Date('2026-07-31T00:00') | Local midnight |
new Date(2026, 6, 31) | Local, and month 6 is July |
Date-only strings being UTC while date-time strings without an offset are local is why a date picker shows the 30th to users west of Greenwich and the 31st to everyone else. Every date library that ever got popular exists because of this.
Temporal is the standardised fix and it is finally real: it reached Stage 4 and is part of ES2026, Firefox has shipped it since 139 in 2025, and Chrome and Edge since 144 in January 2026. Safari is the holdout, with support in Technology Preview rather than a shipping release as of this writing, so a polyfill is still needed if you support Safari. Check the current status before you drop it.
What makes Temporal worth the migration is that its types are the distinction this article is about. Temporal.Instant and Temporal.ZonedDateTime for things that happened, Temporal.PlainDateTime and Temporal.PlainDate for wall-clock values with no zone attached. If you model your storage on those four types you will not need the rest of this article.
What we store, per case
The whole thing collapses into a short table. This is what we use, and the reasoning is always the same: store what the user meant, derive everything else.
| What it is | Store | Why |
|---|---|---|
Log line, payment, created_at | UTC instant | Already happened, conversion is fixed forever |
| Event as experienced by a user | UTC instant plus their IANA zone | So you can render "22:14 your time" later |
| Future appointment, local time | Local date-time plus IANA zone | Survives rule changes; UTC does not |
| Recurring schedule | Rule plus zone, expanded on read | Materialised instants rot after every DST change |
| Birthday, invoice date, deadline day | Plain date | It has no time, so giving it one invents information |
| Flight, train, TV broadcast | Local time plus zone at each end | Departure and arrival live in different rule sets |
| Elapsed time | Monotonic clock reading | Wall clocks jump, both from NTP and from users |
Both of the following cost nothing and save afternoons. Put UTC on every machine, every container and every CI runner, so that a formatting difference between staging and production is impossible by construction. And when timestamps leave your system in a file, quote them as full RFC 3339 strings and never as anything Excel might interpret, because it will happily rewrite 2026-07-31 into something else on open, which we documented in why Excel ruins CSV files.
Timestamp questions
Should I store dates in UTC?
Store past events in UTC, and store future events that were agreed in local time as a local date-time plus an IANA timezone name. A timestamp of something that already happened is a fixed point on the timeline and UTC describes it exactly. A meeting at 9:00 next March is a promise about a wall clock, and converting it to UTC today freezes a timezone rule that governments change several times a year.
What is the difference between timestamp and timestamptz in Postgres?
Neither type stores a timezone. Both are 8 bytes. timestamptz converts the input from the session timezone to UTC on write and converts back to the session timezone on read, so it stores an instant. timestamp with no zone stores exactly the wall-clock value it was given and ignores timezones entirely. The name is the most misleading thing in the Postgres type system: timestamptz is best read as "timestamp, normalised to UTC", not "timestamp with a timezone attached".
Is +02:00 a timezone?
No, it is a UTC offset, which is the result of applying a timezone’s rules at one particular moment. Europe/Vienna is a timezone: a named set of rules that says when the offset is +01:00, when it is +02:00, and what it was in 1980. An offset tells you how to convert one instant and nothing else, so a value stored as +02:00 cannot survive a rule change, and it cannot tell you what the correct offset will be next year.
How should I store a recurring meeting time?
Store the recurrence rule plus the IANA timezone it was defined in, and expand it to concrete instants only when you need them. Storing pre-computed UTC instants for a weekly 09:00 meeting means every row is wrong after the next DST transition or the next timezone rule change in that country. The iCalendar format (RFC 5545) does exactly this with RRULE and a TZID, which is a reasonable model to copy even when you are not producing calendar files.
What is the best format for storing or transmitting timestamps?
RFC 3339, the strict profile of ISO 8601 used across the web, for instants: 2026-07-30T14:05:00Z. For values where the timezone matters and not just the offset, RFC 9557 (published 2024) extends that syntax with a bracketed IANA name, as in 2028-03-15T09:00:00+01:00[Europe/Vienna]. That is the format the JavaScript Temporal API produces, and it is the only widely specified way to carry the instant and the zone in one string.
Does Unix time include leap seconds?
No. POSIX defines Unix time as a formula with exactly 86400 seconds per day, so leap seconds do not exist in it: a Unix timestamp either repeats a value or the extra second is simply absorbed, depending on how the machine is synchronised. 27 leap seconds have been inserted since 1972, the last one at the end of 2016. Google and AWS avoid the discontinuity by smearing the extra second across a 24-hour window, which means their clocks disagree with unsmeared clocks by up to half a second on those days.
Why is JavaScript’s Date object considered bad?
Because it is a 1995 copy of an early Java API that was itself deprecated in 1997. Months are zero-indexed, Date objects are mutable, there is no support for any timezone other than the host’s and UTC, and the parsing rules are inconsistent: new Date("2026-07-31") is parsed as UTC midnight while new Date("2026-07-31T00:00") is parsed as local midnight, which produces off-by-one dates in half the world. The Temporal API is the standardised replacement.
How often does the IANA timezone database change?
Several times a year, with no fixed schedule, because it is driven by government announcements. 2023 alone had four releases, and two of them were caused by a single country changing its mind about DST within five days. Every layer of your stack ships its own copy (the OS, the JVM, ICU, browsers, Postgres), they update at different times, and a machine with a stale tzdb converts future local times to the wrong instant without any error.