Timezones, UTC and ISO 8601: dates without bugs

7 min read
datetime

Almost every "the date is off by one" or "this worked in dev but not in production" bug traces back to one of three mistakes: mixing timezone-aware and naive values, formatting ambiguously, or misunderstanding what a Unix timestamp actually represents.

Store instants in UTC, always

A server should store and compare timestamps as UTC (or as a Unix epoch value, which is inherently UTC by definition). Storing "local time" without its offset means the same stored value could mean different real moments depending on which user's timezone you assume when reading it back — this is the single most common source of timezone bugs in web applications.

But UTC storage is only half the job

The other half is: display converted to the *viewer's* timezone, not the server's. A timestamp stored correctly in UTC but always rendered in the server's local timezone (often UTC itself, on cloud infrastructure) will look wrong to every user not in that timezone. The conversion has to happen at render time, using the timezone the browser or user profile reports — not baked into storage.

ISO 8601: one format, no ambiguity

ISO 8601 is the format that eliminates "is that day/month or month/day" guessing:

2026-08-03T14:30:00Z
  • `2026-08-03` — year-month-day, always in that order, always zero-padded.
  • `T` — separates date from time.
  • `14:30:00` — 24-hour time, no AM/PM ambiguity.
  • `Z` — "Zulu", meaning UTC. An explicit offset like `+02:00` is equally valid and means the same instant expressed at a different local offset.

Compare that to `08/03/2026`, which is unambiguous only if you already know whether the source is American (month/day) or most-of-the-rest-of-the-world (day/month) convention — a date string like `03/08/2026` is silently wrong half the time if you guess incorrectly. ISO 8601's biggest practical benefit is that it also sorts correctly as a plain string, since the components go from largest to smallest unit.

Unix timestamps: what the number actually means

A Unix timestamp is the number of seconds (or, in JavaScript, milliseconds) since 1970-01-01T00:00:00Z. It has no timezone component by definition — it names an instant, not a local time — which is exactly why it is safe to store and compare directly, and why converting it to a human-readable string always requires picking a timezone to render it in.

The recurring bug here is mixing seconds and milliseconds: JavaScript's `Date.now()` and `new Date(x)` expect milliseconds, while most backend languages and Unix tooling default to seconds. A timestamp that looks like `1735689600` in seconds becomes a date around 1970 if fed to `new Date()` unmultiplied, and a date thousands of years in the future if a millisecond value is treated as seconds.

Daylight saving time breaks naive arithmetic

Adding "24 hours" to a timestamp is not the same as adding "one day" in a timezone that observes daylight saving — on the day clocks change, a day is 23 or 25 hours long in wall-clock terms. Naive arithmetic like `timestamp + 86400` is correct in UTC (UTC never has DST), but "add one calendar day and keep the same local time" requires timezone-aware date libraries that understand the applicable DST rules, not raw second addition.

Ranges and "today" are timezone-dependent

"All orders placed today" means a different UTC range depending on which timezone defines "today" — a customer in Tokyo and one in Los Angeles disagree on when midnight happens by up to 18 hours. Any query filtering by a calendar day needs to convert the boundaries using the relevant timezone (the user's, the business's, whichever is contractually meaningful) before comparing against UTC-stored timestamps, rather than truncating a UTC timestamp and assuming that lines up with any particular timezone's midnight.

A short set of rules that prevents most bugs

  • Store instants as UTC or as an epoch value; never store a naive local time without its offset.
  • Convert to the viewer's timezone only at display time.
  • Use ISO 8601 for any date that crosses a serialization boundary (API responses, logs, config).
  • Know whether a Unix timestamp field is in seconds or milliseconds before doing arithmetic on it.
  • Treat "add a day" as a calendar operation in a specific timezone, not a fixed 86400-second offset, whenever daylight saving applies.

Tools from this article

← All articles