toolhq.io

All posts
7 min readby Jameel Haider

Time zones and UTC offsets: why storing +05:00 is not enough

Almost every time zone bug comes from the same confusion: treating an offset as if it were a time zone. +05:00 is an offset, a fixed number of hours from UTC at one particular instant. Asia/Karachi is a time zone, a set of rules that determines which offset applies on any given date, including past and future changes to those rules. An offset describes a moment; a zone describes a place. Store the wrong one and your data is correct today and wrong in March.

You can convert between epoch seconds, ISO strings, and local time with the timestamp converter.

Offset, zone, and abbreviation

Three things get used interchangeably and are not the same:

ExampleWhat it is
Offset+05:00, -08:00Distance from UTC at one instant
ZoneEurope/BerlinRules mapping instants to offsets
AbbreviationCST, ISTAmbiguous shorthand, avoid

Abbreviations deserve their own warning: CST means Central Standard Time in North America, China Standard Time, and Cuba Standard Time. IST covers India, Ireland, and Israel. BST is British Summer Time or Bangladesh Standard Time. They are not unique, they are not standardized, and no software should parse them.

Zone names come from the IANA time zone database, in Area/City form. The city is a representative location, not a claim about administrative boundaries, and the names are chosen for stability rather than politics. That database is updated several times a year, because time zone rules are political decisions that change with weeks of notice, and your runtime, your database, and your browser each carry their own copy that can drift out of date.

UTC, GMT, and Unix time

UTC is the reference standard, kept within 0.9 seconds of astronomical time by inserting leap seconds. GMT is a time zone that happens to have a zero offset in winter and shifts to +01:00 in British summer, which is why Europe/London is not a synonym for UTC. In casual writing they are interchangeable; in code they are not.

Unix time counts seconds since 1970-01-01T00:00:00Z and, by definition, ignores leap seconds: a leap second is absorbed by repeating a value rather than adding one. This is why Unix time is not strictly the count of elapsed SI seconds, and why it is nonetheless the right thing to store, since arithmetic on it is uniform. Unix timestamp and epoch time explained covers the format itself, and ISO 8601 explained covers the human readable equivalent.

Where daylight saving breaks things

DST creates two anomalies each year, and both produce real bugs.

The spring gap. When clocks jump from 02:00 to 03:00, local times between them do not exist. A job scheduled at 02:30 local either never fires or fires at an interpretation your library picked for you. Recurring cron jobs at that hour are a classic source of missed runs.

The autumn overlap. When clocks fall back, local times between 02:00 and 03:00 occur twice. A local timestamp in that window is genuinely ambiguous, and ordering by it produces sequences that appear to go backwards. Events logged in local time during the overlap cannot be reliably sorted.

Both are why a stored local time plus a zone name is not sufficient for a past event: you also need to know which of the two occurrences it was. Storing the instant sidesteps this entirely.

The rules that actually work

Store instants in UTC. For anything that already happened, a created_at, a log line, a payment, store the instant: epoch milliseconds, or a timestamptz in Postgres, or an ISO 8601 string ending in Z. Convert to local time only at display, using the viewer's zone. The instant is a fact; the local rendering is a presentation choice, and different viewers legitimately see different values for the same row.

Store future local events as local time plus a zone name. This is the exception people miss. A meeting at 09:00 in Europe/Berlin on a date six months out should be stored as exactly that, 2027-03-15T09:00 plus Europe/Berlin, not converted to UTC at creation time. If Germany changes its DST rules in the meantime, the UTC value you computed becomes wrong, while the local time plus zone stays correct. The same applies to recurring schedules and alarms: users mean "09:00 wherever I am", not "07:00 UTC".

Never store an offset alone for future events. 2027-03-15T09:00+01:00 looks precise and is unrecoverable if the rules change, because you cannot derive the zone from the offset.

Do arithmetic carefully. Adding 24 hours to an instant is not the same as adding one day in a zone that shifts overnight; one produces 23 or 25 hours of wall clock time, the other does not. Decide which one you mean. Elapsed durations belong in UTC; calendar arithmetic belongs in the zone.

Set the server and database to UTC so that any accidental local interpretation is at least consistent and obvious rather than dependent on where the machine happens to run.

Getting the zone right in the browser

The viewer's zone is one call away, and it returns an IANA name rather than an offset:

Intl.DateTimeFormat().resolvedOptions().timeZone   // "America/Chicago"

Formatting for display, using the instant plus an explicit zone:

new Intl.DateTimeFormat('en-GB', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: 'Asia/Tokyo',
}).format(new Date(1770000000000))

Date.prototype.getTimezoneOffset() returns the offset for one instant in the runtime's zone, and returns it with the opposite sign to what you expect: a zone at +05:00 reports -300. It is a common source of double negation bugs, and there is rarely a reason to call it now that Intl exists.

The modern replacement for Date arithmetic is the Temporal API, which separates the concepts explicitly: Temporal.Instant for a moment, Temporal.PlainDateTime for a local wall time with no zone, and Temporal.ZonedDateTime for the combination. If your runtime has it, the type system stops most of these bugs before they happen.

Checking your work

The fastest sanity check on any stored value is to convert it and see whether it reads sensibly for the zone you expect. The timestamp converter turns epoch values into ISO and local strings in both directions, which is usually enough to tell whether a value was stored as an instant, a local time misrepresented as UTC, or a double converted time that is off by exactly one offset. That last pattern, a value wrong by precisely the offset, is the signature of converting a time that was already UTC.