Time Is Not One Field: UTC, Time Zones, and Monotonic Clocks
“Store every time in UTC” is often presented as a universal rule. It solves only part of the question “when did this happen?” A birthday is not an instant, “every day at 09:00” is not a fixed number of seconds, and a 30-second timeout must not change when the operating system corrects its clock. Production code has to identify the kind of time that the domain actually uses.
Five concepts need different types
- An instant is one point on the timeline, such as a payment completion.
- A local date is a calendar day without a time zone, such as a birthday.
- A local date-time is a wall-clock reading that can be missing or repeated.
- A zoned schedule combines local time, an IANA zone, and recurrence rules.
- A duration is elapsed time between operations, such as a timeout.
Names should preserve those semantics: created_at, birthday, starts_local_at, time_zone, and timeout_ms are clearer than one generic time field.
Exchange completed events as instants
A database can store a UTC instant or Unix time. An API can use an RFC 3339 timestamp with an explicit offset:
{
"completed_at": "2026-10-20T02:32:11.418Z"
}
Reject date-times without an offset instead of guessing whether they refer to the server, device, or user’s zone. Standardize precision too. If the contract promises milliseconds, do not let one service emit three fractional digits while another emits nine. Compare parsed instants internally rather than display strings.
Keeping the original offset can help an audit explain what a user saw, but an offset is not a time zone. +08:00 cannot describe future daylight-saving transitions, and an abbreviation such as CST can identify several regions. Store an IANA identifier such as Asia/Shanghai or Europe/Paris whenever local-time meaning matters.
Do not disguise a date as midnight
Birthdays, due dates, and business days belong in a date type or YYYY-MM-DD. Converting a birthday to 2000-01-01T00:00:00Z can display as the previous day in the Americas. Date queries should use half-open intervals. To query October 20, map the start of that local day and the start of the next day to instants, then apply start <= t < end. Do not manufacture an end value such as 23:59:59.999.
Store the intent of a recurring schedule
“Every Monday at 09:00” means wall-clock time in the user’s zone, not every 604,800 seconds. Store the local time, IANA zone, recurrence rule, and the product’s DST policy. Calculate the next instant close to execution. Governments change time-zone law, and IANA updates tzdb accordingly. Permanently expanding a year of future occurrences into UTC can leave jobs running at the wrong local time after such a change.
A forward DST transition creates local times that do not exist. A backward transition repeats a range of local times. The product must choose a policy: move to the next valid time, skip the occurrence, select the earlier or later instant, or ask the user. A library default is not a product decision. RFC 9557 can carry an offset and time-zone name together, but an API still has to define what happens if they disagree.
Measure elapsed time with a monotonic clock
A wall clock can jump when NTP adjusts it, an administrator changes it, or a virtual machine resumes. Request deadlines, lock countdowns, retry delays, and performance measurements should use a monotonic clock. It cannot represent a calendar date, but it is designed not to move backward.
started = monotonic_now()
deadline = started + 30 seconds
remaining = deadline - monotonic_now()
A monotonic value is meaningful only inside its system and runtime context. Never persist it and compare it across machines. A durable expiry remains a wall-clock instant; while the process runs, convert it into a remaining duration and define whether system suspension consumes that duration.
Treat time-zone data as a dependency
IANA tzdb records historical and predicted civil-time rules. Predictions can become wrong after a legal change. A runtime should expose its tzdb version, and both servers and clients need an update policy. When a rule changes, recalculate future schedule instances and notify affected users. Preserve the original instant and relevant zone context for completed events instead of rewriting history with new rules.
Do not use timestamps as the sole ordering mechanism across services either. Machines have clock skew, and events can share the same timestamp precision. Use a database sequence, entity version, message offset, or causal identifier for business ordering. A timestamp is useful for presentation and approximate searching; it is not a global consistency protocol.
Test the boundaries
Fixtures should cover skipped and repeated DST times, month ends and leap days, negative and non-hour offsets, ranges crossing midnight, tzdb updates, wall-clock jumps in both directions, deadlines across suspension, and clients sending an offset-free date-time. Inject the clock and select the zone explicitly so tests do not depend on the machine that runs them.
Before release, confirm that every field is an instant, date, local time, schedule, or duration; instants use an offset-bearing interchange format; future local rules retain an IANA zone; DST ambiguity has a product policy; elapsed work uses a monotonic clock; tzdb is observable and updatable; business ordering does not depend on wall time; and boundary tests use controlled clocks.
Correct time design does not force every concept into one representation. UTC identifies completed instants, time zones interpret human calendars, monotonic clocks measure elapsed work, and sequence identifiers establish consistent order. Once those boundaries are explicit, time stops silently changing meaning across regions and years.