The rules

Every “always” and “never” in this guide, distilled into one deep-linkable list — share a rule by its link.

This guide spends its pages on why. This one is the what to do: the guide’s recurring edicts, stripped to imperatives and collected in one place. Every rule’s heading is a link to itself — click one and copy the address to cite exactly that rule in a code review or a design doc. Each rule also links to the page that earns it, including the cases where it bends.

The pitfalls index is this page’s negative mirror: what it looks like when these rules are broken, with the incidents to prove it.

Storing & modeling

Store instants in UTC

An instant — something that happened or will fire at a point on the timeline — is stored in UTC, as RFC 3339 text or a database instant type, and converted to a zone only at the edges. → Store vs display

Give civil values a civil type

A birthday, an invoice date, a “9 AM” preference has no zone and names no instant. Storing one as a UTC timestamp invents a time and a zone it never had, and can shift the date by a day. → Store vs display, What is a date?

Keep the zone next to anything you must re-localize

An offset pins an instant; only the IANA zone name carries the rules for future readings. If a value must come back as a wall-clock time — an event venue, a user preference — store America/New_York with it, not -05:00. → Zones vs offsets

Store recurrences as a rule, not occurrences

“Every day at 9 AM” is a civil rule plus a zone plus exceptions. Materialized UTC instants drift at every DST transition and break when zone rules change; expand the rule as late as possible. → Recurring events

Make the type say what the value means

The schema is the one piece of documentation the database enforces. A civil date in a timestamptz, or an instant in a bare string, tells every other consumer a lie. → Your schema is a contract

Name fields with kind, basis, and units

created_at for an instant, due_on for a date, timeout_ms for a duration. The name is read by people who will never see your code — make it answer what the type cannot. → Naming time fields

Exchanging

Document the wire format

The core interchange rule is not “use format X” — it is that both ends know, in writing, exactly which format is in play. RFC 3339 in UTC is the safe default to document. → Your own client ↔ server, RFC 3339

Never emit a timestamp without an offset

2026-06-05T14:30:00 forces every consumer to guess the zone, and each will guess differently. Always Z or an explicit ±hh:mm. → RFC 3339

Treat third-party timestamps as untrusted input

Verify the format, the unit (seconds vs milliseconds), and the basis before the first value crosses into your system — APIs rarely label any of them, and they change without notice. → 3rd-party data

Never parse human input leniently

Free-text dates are locale-dependent and ambiguous; a lenient parser silently guesses. Parse strictly against known formats, use the user’s locale, and ask when it is still ambiguous. → Parsing user input

Displaying

Format late, with an explicit zone and locale

Formatting is presentation: keep values precise until the rendering layer, then hand them to a locale-aware formatter with the zone and locale supplied deliberately — never inherited silently from the environment. → Formatting for display

Never hand-assemble a date string for a person

Concatenating day, month, and year yourself hard-codes one locale’s order and punctuation and gets pluralization, month names, and right-to-left scripts wrong. Delegate to the platform’s formatter. → Formatting for display

Computing

Measure elapsed time with a monotonic clock

The wall clock jumps — NTP corrections, DST, a user changing the time. For durations, timeouts, and benchmarks, use the clock that only moves forward. → Clocks

Do calendar math in civil time, through a zone

“Tomorrow” and “next month” are civil steps, not fixed numbers of seconds. Adding 86 400 seconds lands an hour off at every DST transition; step the calendar date in the zone instead. → DST, Durations

Never assume a day has 24 hours

DST gives some local days 23 and some 25; gaps mean some local times never exist, and overlaps mean some happen twice. Code that assumes otherwise breaks exactly twice a year. → DST


← Back to all topics