tech, developers, and the code underneath

issue 172· essay·

Time zones, and why your calendar code is wrong

An instant is not a time. A time is not a date. The rules change by government decree with a few weeks' notice.

Date and time handling is the domain where confident code is most reliably wrong, because the domain is defined by politics rather than physics and it changes without warning.

the three distinct things#

Most bugs come from conflating these.

1. An instant. A specific moment in the history of the universe. Stored as a UTC timestamp or an epoch value. "The order was placed at 2026-06-03T14:22:11Z."

This is what you want for: logs, audit trails, created-at, anything that records something that happened.

2. A local date and time with a zone. "The meeting is at 09:00 on 2026-09-15 in Europe/Berlin."

Critically, this is not convertible to an instant in advance without risk, because the offset for Europe/Berlin on that date depends on the rules in effect at that time, and the rules can change between now and then.

This is what you want for: future scheduled events, business hours, recurring appointments.

3. A plain date. "2026-06-03." No time, no zone. A birthday. A contract date. An invoice period.

Storing a birthday as an instant is a bug. It will shift across the date boundary for users in some zones and someone will be wished a happy birthday on the wrong day.

the rules#

Store instants in UTC. Always. timestamptz in Postgres, never timestamp. Convert at the display boundary.

Store future events as local time plus a zone identifier, not as a UTC instant.

This is the one people get wrong most often, and it is the most consequential.

If a user schedules a meeting for 09:00 on 15 September in Berlin, and you convert that to UTC now and store the instant — and Germany changes its daylight saving rules in July, which is a thing legislatures do — your meeting is now at the wrong time.

Store ("2026-09-15T09:00", "Europe/Berlin"). Convert at display time, using current rules.

Use IANA zone identifiers, never offsets. America/New_York, not UTC-5 and not EST.

An offset is a fact about one instant. A zone is a set of rules over time. Abbreviations are worse: CST is Central Standard Time, China Standard Time, and Cuba Standard Time, and there is no way to tell which.

Never do arithmetic on local times. Adding 24 hours to a local time is not the same as adding one day. On a DST transition day, one is 23 hours and the other is 25.

Decide which you mean:

  • "24 hours later" — arithmetic on the instant.
  • "the same time tomorrow" — arithmetic on the local calendar date, then convert.

These differ twice a year and the bug reports are seasonal.

Keep your timezone database updated. The IANA database is updated several times a year because governments change rules, sometimes with weeks of notice.

If your application bundles a tzdata snapshot from two years ago, it is wrong for several countries right now. This includes container images, JVM installations, and some language runtimes' bundled copies.

the specific traps#

DST transitions create times that do not exist and times that occur twice. At a spring-forward transition, 02:30 local does not exist. At fall-back, 01:30 happens twice.

Your date library has a policy for this. Know what it is. The usual options are throw, shift forward, or pick the earlier occurrence, and the right one is application-specific.

Leap seconds. Mostly abstracted away by clock smearing at the infrastructure layer, and worth knowing exists. A minute is not always 60 seconds.

Countries change zones. Not just DST rules — entire offsets. Several countries have shifted their standard time in recent decades.

"Midnight" is ambiguous. Is 2026-06-03T00:00 the start or the end of 3 June? Prefer half-open intervals: [start, end). A day is from midnight inclusive to the next midnight exclusive. This eliminates an entire category of off-by-one.

The user's zone is not their locale is not their country. A user in Berlin may want dates in US format. Store these as separate preferences.

Recurring events are their own discipline. "Every Tuesday at 09:00" across a DST boundary means the UTC instant changes. Store the rule, not the instances, and expand at read time.

the practical advice#

Use a real date library. Not string manipulation, not manual arithmetic. The edge cases are numerous, well known, and already handled by people who spent years on them.

In JavaScript, the Temporal API finally provides the right distinctions natively — Instant, ZonedDateTime, PlainDate map exactly onto the three things above, which is why it took so long to design.

Test at boundaries. Your test suite should include a DST transition, a leap day, a year boundary, and a zone with a non-hour offset — India is UTC+5:30, Nepal is UTC+5:45, Chatham Islands is UTC+12:45. Code that assumes whole-hour offsets is common and wrong.

Never store or transmit local time without a zone. A timestamp without a zone is an ambiguous string and someone downstream will guess, and they will guess UTC, and they will be wrong by up to fourteen hours.

Dom, June 3, 2026

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →