How to store dates and times correctly

Past instants go in UTC. Future local events go as a wall-clock time plus a zone name. Plain dates stay plain. Most bugs come from mixing the three.

Dates and times in software go wrong in predictable ways, and most of them trace back to storing the wrong kind of value. There are three kinds, and they need different treatment: an instant, which is a point on the timeline; a local date-time, which is what a clock on a wall reads somewhere; and a plain date, which has no time at all. The Unix time converter and the date format converter move between representations; this guide is about which representation to keep.

Instants: store UTC

When something happened, such as a record being created, a payment going through or a log line being written, it is an instant. Store it in UTC, either as an ISO 8601 string with a Z or as a Unix timestamp, and never in local time. Local time is ambiguous for one hour a year wherever daylight saving ends, it depends on the server’s configuration, and it breaks the moment a server moves or a second one is added in another region. Convert to a local zone only when displaying to a person, and do the conversion with the viewer’s zone, not the server’s.

Future local events: store wall time and zone

A meeting at 09:00 next April in Paris is not an instant, however much it looks like one. If you convert it to UTC today, you commit to the offset in force on that date as the rules stand now. If France abolishes daylight saving before then, as the EU has debated, the stored UTC value will be an hour off from what everyone in the room expects. Store the local date-time, 2027-04-14T09:00, together with the zone name, Europe/Paris, and convert to an instant as late as possible, with whatever rules are current at that moment. The time zone database guide explains why the rules are a moving target.

Recurring events belong to this category too. “Every Monday at 09:00 Paris time” is a wall-clock rule, and a system that stores it as “every 604,800 seconds from the first occurrence” will drift by an hour at each clock change. Store the rule and the zone, and compute each occurrence from them.

Plain dates: store a date

A date of birth, a public holiday or a contract start date has no time and no zone, and attaching one creates bugs. The classic one is storing a birthday as midnight UTC, then displaying it in a zone west of Greenwich, where midnight UTC is the previous evening and the birthday shows a day early. Use a date type, DATE in SQL, LocalDate in Java, a YYYY-MM-DD string in JSON, and keep it free of time entirely. The date format converter works on this kind of value.

Why an offset is not enough

A value such as 2027-04-14T09:00:00+02:00 pins down an instant exactly, and for a past event that is fine. For a future one it records the offset the author assumed, not the zone. Several zones share +02:00, and the one meant may not be on +02:00 by the time the date arrives. Offsets convert; zone names predict. Keep the zone name when the value refers to the future.

Database column types

PostgreSQL’s timestamptz, despite the name, stores no zone: it converts the input to UTC and stores that, then converts back to the session’s zone on output. It is the right type for instants. timestamp without time zone stores the wall-clock digits with no conversion, which is right for a local date-time but a trap for an instant, because what you read back depends on nothing you can see. MySQL’s TIMESTAMP behaves like timestamptz but, in older versions, stops at the 2038 limit described in the 2038 guide; DATETIME stores digits with no conversion. SQLite has no date type at all and stores whatever text or number you give it, so a convention has to be chosen and kept. In every case, store the zone name in a separate text column when a future local time needs one.

Transport: ISO 8601 with an explicit offset

Between systems, send instants as ISO 8601 strings ending in Z, or as integer Unix timestamps with the unit stated in the field name (createdAtMs). Send local date-times without an offset and with the zone name in a separate field. Send plain dates as YYYY-MM-DD. Never send a local time without saying what it is local to. The ISO 8601 guide has the details of the format.

JavaScript parsing traps

new Date('2026-10-28') parses as midnight UTC; new Date('2026-10-28T00:00') parses as midnight local time. Those are different instants everywhere except Greenwich, and the inconsistency is in the standard. Date objects are instants and have no zone of their own, only methods that read them in UTC or in the device’s zone. Month numbers in the old API run from 0. new Date('28/10/2026') is invalid in some engines and the 10th day of the 28th month in others. The safe path is to parse only ISO strings with explicit offsets, to use the Temporal API where it is available, and otherwise to do calendar arithmetic on year-month-day fields rather than on milliseconds.

A short set of rules

Store instants in UTC and display them in the viewer’s zone. Store future local events as a wall-clock time plus a zone name, and convert late. Store plain dates as dates. Keep the zone name, not the offset, whenever the value looks forward. Name the unit of every numeric timestamp. And test with a zone that has a half-hour offset and a daylight saving change, such as America/St_Johns, because a system that handles Newfoundland handles everything.

Sources

About the author

UnixClock is built, maintained and written by Niraj Kumar, a software developer who started it to replace the hand calculations and half-remembered offsets that kept going wrong across time zones. More about the site.

Frequently asked questions

Should I store dates in UTC or local time?

Instants, meaning things that already happened, in UTC. Future local events, such as a meeting at 09:00 in Paris next spring, as the local wall-clock time plus the zone name, converted to UTC only when needed.

Does PostgreSQL timestamptz store the time zone?

No. It converts the input to UTC, stores that, and converts to the session zone on output. Store the zone name in a separate column if you need it.

Why does my birthday field show the wrong day?

Usually because the date was stored as midnight UTC and displayed in a zone west of Greenwich, where that instant is the previous evening. Store dates of birth as plain dates with no time.

Why does new Date('2026-10-28') give a different time from new Date('2026-10-28T00:00')?

Because the JavaScript standard parses a date-only string as UTC and a date-time string without an offset as local time. Always include an explicit offset or Z.

Related tools