How to Store Dates and Times Without Time Zone Bugs

By Talk & Tool4 min read

"Always store UTC" is good advice about half the time. It is right for things that already happened and wrong for several kinds of things that have not, and following it blindly produces a class of bug that only appears months later, in another country, after a government changes its clocks. The fix is to decide what kind of time value you are holding before choosing how to store it.

There are three kinds of time value

Instants are moments that happened at one point on the global timeline: a login, a payment, a log line, a record's created-at. Store these as UTC, or as an epoch timestamp, and convert to local time only when displaying. Nothing about the future can change when they happened. The timestamp converter is handy for reading these back while debugging.

Future local events are wall-clock times people agreed on: a dentist appointment at 09:00 in Lisbon, a weekly stand-up at 10:00 in New York. Store the local date-time and the IANA time zone name (Europe/Lisbon, America/New_York), and calculate the UTC instant when you need it.

Floating dates and times have no zone at all: a birthday, a public holiday, "the shop opens at 08:00". Store them as a plain date or plain time with no zone attached.

Why future events should not be stored in UTC

Time zone rules are political and they change, sometimes with only weeks of notice. Countries abolish daylight saving time, move their offset, or change the transition date.

Suppose a meeting in March next year is saved as UTC today, using today's rules. If the government changes its daylight-saving transition in the meantime, your stored UTC value now corresponds to 08:00 or 10:00 local time. The attendees still expect 09:00, and your system confidently sends reminders for the wrong hour. Store the intent — 09:00 in that zone — and resolve it with the current rules at the time you need it, and the change is absorbed automatically once the time zone database updates.

The midnight-UTC birthday bug

A birthday stored as a timestamp at midnight UTC is displayed as the previous day for every user west of Greenwich. This is one of the most common date bugs in production software, and it comes from treating a floating date as an instant.

JavaScript makes it easy to trip over. new Date('2026-03-01') is parsed as midnight UTC, while new Date('2026-03-01T00:00') is parsed as midnight local time. The two differ by several hours for most of the world, and formatting either one for a user in another zone can shift the calendar day. If a value is really a date, keep it as a YYYY-MM-DD string or a database DATE column and never pass it through a timestamp.

Offsets are not time zones

+01:00 tells you the offset at one moment. It does not tell you whether that location observes daylight saving time, when it changes, or what the offset will be next month. Paris and Lagos share +01:00 in winter and differ in summer.

That is why "add one week" and "repeat every Monday at 09:00" need the zone name, not an offset. An instant plus seven days of elapsed time is not always the same wall-clock time a week later; around a daylight-saving transition it is an hour off. Calendar arithmetic of this kind — including month-end edge cases — is what the date add and subtract tool performs on calendar dates.

Abbreviations are worse still. CST can mean Central Standard Time in North America, China Standard Time or Cuba Standard Time. Never store or parse them.

Serialise with ISO 8601

When a value leaves your system, use ISO 8601. An instant should include its offset or Z: 2026-09-13T14:30:00Z. A local event can be sent as a local date-time plus a separate zone field. A date is just 2026-09-13. These strings sort correctly as text, are unambiguous across locales, and are parsed by every mainstream language.

Avoid locale formats such as 09/10/2026 in any data that crosses a system boundary; it is 10 September in the US and 9 October almost everywhere else.

Database column choices

  • PostgreSQL: timestamptz for instants (it stores UTC and converts on the way out), date for floating dates, and a timestamp plus a text zone column for future local events.
  • MySQL: DATETIME for stored UTC values that your application converts, noting that TIMESTAMP has a 2038 upper limit and applies the session time zone on read.
  • Set the database server and application servers to UTC, so no conversion depends on where a machine happens to be hosted.

Test with the zones that break things

Run date tests under a zone west of UTC, a zone east of UTC, a zone with a half-hour offset such as Asia/Kolkata, and a date that spans a daylight-saving transition. Bugs that are invisible in UTC show up immediately. For user-facing deadlines, a live countdown in the browser is a quick way to see whether a stored instant resolves to the moment you expected in the viewer's local zone.

Related reading