Time zones, dates and UTC: getting it right

Date and time bugs happen when code mixes different concepts: an instant, a local date, a local time, a time zone, and an offset. Treat them as separate values and many failures d…

Date and time bugs happen when code mixes different concepts: an instant, a local date, a local time, a time zone, and an offset. Treat them as separate values and many failures disappear.

Use the right model

An instant is a single point on the timeline. It can be stored and compared without knowing where the user is.

A local date is a calendar date without a time or time zone, such as a birthday or a reporting day. It should not be converted to midnight UTC unless the domain really means that instant.

A local date and time is a wall-clock value, such as 2026-10-25 01:30. It may be ambiguous or invalid in a time zone with daylight saving transitions.

A time zone is a ruleset for a region, such as Europe/London. It is not the same as an offset. A zone can use different offsets at different times.

An offset is the difference from UTC at one instant, such as +00:00 or +01:00. It does not contain future daylight saving rules.

Store instants for events that happened

For events that happened at a specific moment, store an instant. Examples include audit entries, login attempts, payment captures, build starts, queue message creation, and log records.

Serialise instants with an explicit offset. For internet timestamps, RFC 3339 defines a widely used profile of ISO 8601. UTC is commonly written with Z.

{
  "createdAt": "2026-06-04T10:15:30Z"
}

Do not serialise a timestamp without an offset unless the receiving system explicitly expects a local date and time. A value such as 2026-06-04T10:15:30 is not enough to identify an instant.

Keep local dates as local dates

Some values are dates, not instants. Examples include birthdays, licence renewal days, holiday dates, invoice dates, and calendar days in a user's locale.

Store these as dates when the domain is date-based.

{
  "renewalDate": "2026-06-04"
}

Converting a date to midnight UTC can move it to the previous or next local day for users in other time zones. That is a modelling error, not a formatting issue.

Store the time zone when future local time matters

Future scheduled events often need a time zone, not just an instant. A meeting scheduled for 09:00 in Europe/London should remain 09:00 local time even if the offset changes between winter and summer.

For future human schedules, store:

  • The local date.
  • The local time.
  • The IANA time zone identifier.
  • The resolved instant when needed for execution.

This preserves user intent and still allows systems to trigger jobs at the correct instant.

Do not treat offsets as time zones

The offset +01:00 tells you the offset for one instant. It does not tell you whether the location is Europe/London, Europe/Paris, Africa/Lagos, or another region using the same offset at that moment.

If the application needs daylight saving behaviour, legal local time, or future scheduling, store a time zone identifier from the IANA time zone database.

The IANA time zone database is updated when political bodies change time zone boundaries or daylight saving rules. Keep runtime time zone data up to date, especially for scheduling systems.

Handle daylight saving gaps and repeats

Daylight saving transitions create two important cases.

A gap is a local time that does not exist because clocks move forward. A repeat is a local time that occurs twice because clocks move back.

Code that accepts local date and time input must define a policy. It can reject invalid times, ask the user to choose, or apply a documented disambiguation rule. Silent conversion is dangerous because it hides a business decision in library behaviour.

Test around transitions for the zones your users use. Do not only test UTC.

Be explicit at system boundaries

Every API, event, database column, and log field should make its date and time meaning clear. Names help.

Use names such as:

  • createdAt for an instant.
  • localDate for a date without time.
  • timeZone for an IANA zone identifier.
  • startsAt for a resolved instant.
  • startsOn for a local date.

Avoid names such as date, time, or timestamp when they do not specify the model.

Use libraries that expose the distinction

Prefer date and time APIs that distinguish exact time, local date, local time, and zoned date time. In JavaScript, the Temporal proposal is designed around separate types for date-only, instant, and zoned date-time use cases. In other ecosystems, choose equivalent types rather than forcing every value through a single timestamp class.

The important rule is conceptual, not language-specific: do not use one type for every temporal value.

Log in UTC, display in the user's context

Operational logs are easiest to correlate when they use UTC instants. User interfaces are easiest to understand when they display dates and times in the user's expected locale and time zone.

Keep the stored value and displayed value separate. Formatting is a presentation step. It should not change the underlying instant.

Test with real edge cases

Add tests for:

  • A normal day.
  • A daylight saving gap.
  • A daylight saving repeat.
  • A leap year date.
  • A date-only value for a user west of UTC.
  • A date-only value for a user east of UTC.
  • Serialisation with an explicit offset.
  • Parsing invalid or offset-less input.

Use named zones, not only fixed offsets. Fixed offsets do not exercise daylight saving rules.

Conclusion

Getting dates right starts with modelling. Store instants for events that happened, keep local dates as dates, store an IANA time zone for future local schedules, and never confuse an offset with a zone. UTC is excellent for instants, but it is not a replacement for local date and time semantics.