Store vs display
Store instants in UTC; convert to the user's local zone only at display time.
The single most reliable rule in application time handling: store instants as UTC, convert to the user’s local zone only when rendering for display, and never store the formatted display string as your source of truth. UTC is unambiguous, monotonically ordered (modulo leap-second debates), and requires no context to interpret. A display string is the opposite — it is inherently context-dependent and often lossy.
“At display time” means as late as possible: in the rendering layer, not in a background job that pre-computes formatted strings. User time zones change (people move, apps expose a zone preference), and stored display strings cannot be retroactively corrected.
The one important exception: if you are storing a civil-time intent — a 9 AM recurring meeting, a store’s opening hours, a birthday — you are not storing an instant, and converting to UTC up front is actually wrong. Store the civil time and the IANA zone name together, and resolve to an instant only when needed. See Instant vs civil time for why.
Keep zone information separate from the value
A UTC instant carries no zone; that is a feature, not a limitation. Store the user’s display zone separately (a user-preference column, a session value, a request header) and join them at render time. This lets you re-render the same instant in a different zone without touching stored data.
Pitfall: Storing a pre-formatted timestamp string like "June 5, 2026 at 3:00 PM EDT" in a database column. This is irreversible: you cannot reliably parse it back to a precise instant, it breaks for users in other zones, and it will display incorrectly after a DST transition or a user’s zone change.
Model the value with the type that fits it
Reach for the data type that actually models the value, even when a looser one would technically hold it. You wouldn’t store an integer in a floating-point column just because a float can carry it — and the same judgement applies to time. A birthday is a civil date: no time of day, no zone. Storing it as a UTC timestamptz (or a BSON Date, which is a UTC instant) is the float-for-an-int mistake — it invents a midnight and an offset that were never part of the value, and a user in another zone eventually sees the birthday on the wrong day. If your database has a date type, use it; keep timestamp with time zone for actual instants; store civil-time-plus-zone explicitly when that is what you mean. (See Instant vs civil time, and the storage pages for what each engine offers.)
Modelling time this precisely costs more up front — an extra column, an explicit cast, a parse step at the boundary. It is usually worth it. The shortcut (“everything is a UTC datetime”) does not remove the complexity; it defers it to a production incident months later, where it is far more expensive to diagnose than it would have been at schema-design time. Paying the cost early is the cheap version.
Pitfall: Storing a time-less, zone-less value — a birthday, an invoice date, a public holiday — as a UTC instant (timestamptz, BSON Date, epoch millis). The spurious midnight-UTC shifts the date by a day for users far enough east or west, and the stored value now lies about its own precision. Use a date type, or store the civil value explicitly.
Go deeper: offset columns, display zone resolution, and legacy data
Storing the offset alongside the instant. Some applications store both the UTC instant and the original local offset (e.g. 2026-06-05T15:00:00Z plus offset = -04:00). This lets you reconstruct the original local display time even after the user changes their zone preference — useful for audit logs or “show when the user submitted this”. It is not the same as storing the IANA zone name, which would let you re-evaluate DST rules; an offset is a snapshot. See Time zones vs offsets.
Display zone resolution. At render time, resolve the zone in this priority order: (1) user’s explicit zone preference, (2) zone from a request header or IP geo-lookup, (3) server default as a last resort with a visible label. Never silently use the server zone — a server in UTC will show “3:00 AM” to a user in New York who expected “11:00 PM”.
Legacy data without zone info. If you inherit a database full of local timestamps with no zone annotation, resist the temptation to interpret them all as UTC or as one fixed zone. Document the uncertainty, record the best-guess assumption in a migration comment, and prefer to surface the ambiguity to users rather than silently guess wrong.
See also. For the wire format to use between services, see Your own client ↔ server. For civil-time storage, see Instant vs civil time.