toolhq.io

All posts
6 min readby Jameel Haider

Handling time zones in code: store UTC, convert at the edges

Time zones cause more quiet data bugs than almost any other everyday concept, and nearly all of them trace back to one mistake: treating a time zone as a fixed number of hours to add. It is not. The discipline that avoids the whole category is short to state, so here it is, with the reasoning that makes it stick.

The rule: store UTC, convert at the edges

Keep every stored and computed timestamp in UTC. Convert to a local zone only at the two edges of your system: when you accept a time from a user, convert it to UTC immediately; when you display a time to a user, convert from UTC to their zone at the last moment. Everything in between, database rows, internal APIs, comparisons, scheduling math, stays in UTC.

This works because UTC has no daylight saving, no political offset changes, and one unambiguous meaning for every instant. Two UTC timestamps can always be compared and subtracted correctly. The moment you let local times leak into storage or logic, you lose that guarantee.

Why "just add hours" is wrong

The tempting shortcut is to treat a zone as an offset: New York is UTC minus five, so subtract five hours. This is wrong because the offset is not constant. New York is UTC minus five in winter and UTC minus four in summer, and it switches on dates set by law, not by a formula you can hardcode.

Consider converting 2026-11-01T06:30:00Z to New York time. On that date the clocks fall back, and the offset changes partway through the local night. A fixed "minus five" gives the wrong wall-clock time for part of the year, and a time that lands inside the transition can be flat-out invalid or occur twice. The arithmetic is not addition; it is a lookup into a database of rules that change over time.

ApproachHandles DST?Handles law changes?
Add a fixed offsetnono
Use a named zone + tz databaseyesyes, with updates

Offsets vs named zones

This is the distinction that unlocks everything. An offset like +05:30 or -04:00 is a fixed difference from UTC at one instant. A named zone like Asia/Kolkata or America/New_York is a rule set, maintained in the IANA time zone database, that says which offset applies on which dates, including every past and future DST transition.

  • Store or transmit an instant as UTC, or as an offset timestamp that pins the exact moment.
  • Store a user's zone preference as a named zone, never as a raw offset. An offset tells you nothing about what happens next summer.

A booking for "9am next March in London" cannot be stored as a UTC instant alone if the rule might change, nor as an offset, because London's offset in March depends on the DST switch date. Store the local wall time plus the named zone, and resolve to an instant using the tz database when you need one.

The DST pitfalls in practice

Three concrete traps:

  • The skipped hour. When clocks spring forward, a local time like 02:30 simply does not exist that day. Constructing it is an error that libraries resolve differently, some throwing, some shifting forward. Decide what your code should do rather than letting the default surprise you.
  • The repeated hour. When clocks fall back, a local time like 01:30 happens twice. A naive conversion picks one arbitrarily, which can place an event an hour off. If the distinction matters, you need the offset alongside the local time.
  • Recurring events across a transition. "Every day at 9am local" is not "every 24 hours." On transition days the real interval is 23 or 25 hours. Schedulers that add fixed durations drift; ones that recompute from the named zone stay correct.

A checklist

  1. Store timestamps as UTC (or an offset-bearing instant). Never store naive local times.
  2. Store a user's zone as an IANA name, not an offset.
  3. Convert to local time only for display, using a library backed by the tz database.
  4. Keep the tz database updated, since zone rules change by legislation.
  5. Decide explicitly how to handle skipped and repeated local times.
  6. Never compute a local interval by adding a fixed number of hours.

Follow those six and the entire bug class mostly evaporates. The remaining work is choosing a good date library and keeping its tz data current.

When you need to sanity-check a conversion, confirm what UTC instant a local time maps to, or read an offset timestamp as plain UTC, paste the value into the Timestamp Converter.