OperationsGENERALDATABASE-SPECIFICTOOL-SPECIFIC

Production Time Is UTC

Machine timelines, logs, storage and schedules in UTC; local time only at the edges where a human reads it. The conversion belongs in one place.

The question, the obvious approach, and why it breaks

Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.

The production question

Why does every experienced operator insist that production runs on UTC?

The problem

Correlating events across services is only possible if their timestamps mean the same thing. Local time makes two records with the same string refer to different instants, and one instant unrepresentable twice a year.

What teams do first

Store and log times in the timezone the business operates in. It is easier to read, and everyone here is in the same place anyway.

How it breaks

Two log lines an hour apart can carry the same local timestamp during a daylight saving transition, and an incident timeline built from them is wrong in a way that looks right.

How it breaks in production
  • Two log lines an hour apart can carry the same local timestamp during a daylight saving transition, and an incident timeline built from them is wrong in a way that looks right.
  • The moment a second region, a third-party API or a remote colleague appears, timestamps stop being comparable and every correlation becomes a conversion argument.
  • Durations computed by subtracting local times are wrong by an hour twice a year — sometimes silently, sometimes as a negative duration.
  • Sorting by a local timestamp does not sort by time. During the repeated hour, order is genuinely ambiguous.
  • Business boundaries — a billing cutoff, a daily report, a retention window — computed in local time land on the wrong instant for anyone outside that zone.
  • A timestamp without an offset is not a time. It is a string that requires context nobody wrote down.
CodeBuildTestArtifactReleaseDeployRunObserveOperateIncidentRecoverLearnImprove

What is actually happening

Underneath the tooling, which is the part that survives a change of tool.

  • An instant is a point on the physical timeline. A local time is an instant plus a zone rule, and zone rules change: they are political decisions with a version history, distributed as a database that must be updated.
  • The conversion from instant to local time is lossy in both directions during transitions: one local time can map to two instants (the repeated hour), and one local time can map to none (the skipped hour).
  • UTC has no transitions, so instants are totally ordered, subtractable and comparable. That is the entire argument — it is not about preference or convention.
  • Storing an offset is not the same as storing a zone. An offset tells you what the clock read; a zone tells you what the rules were and lets you compute future local times correctly when rules change.
  • For future events with human meaning — "the meeting at 09:00 in Berlin" — the correct storage is the local time plus the zone identifier, because the rules may change before the event arrives. For past events, the instant is what happened.
  • Every layer that reformats a timestamp is a place the zone can be lost: a driver, a serialiser, a log shipper, a dashboard. The defence is one conversion point at the display edge and UTC everywhere behind it.

Instant, local time, and the thing in between

GENERALThe distinction holds in every language and database. What differs is which of these your default type actually is — and the default is frequently the last row.

Most time bugs come from conflating three different concepts that all get called "a timestamp". Separating them makes the storage decision obvious.

ConceptWhat it isStore it whenFailure if misused
Instant (UTC)A point on the physical timelineRecording something that happenedNone — this is the safe default for the past
Local time + zone idWall time plus the rules to interpret itA future human appointment or a local business boundaryStoring an instant instead breaks when zone rules change
Local time + offsetWall time plus what the clock read thenRecording what a human saw, alongside the instantTreated as a zone, it produces wrong future conversions
Naive local timeA string with no zone informationNever, in production storageAmbiguous during the repeated hour; incomparable across hosts
DurationAn elapsed length, not a pointMeasuring how long something tookComputed by subtracting local times, it is wrong across transitions

One conversion point, at the edge

The architectural rule is that time is UTC everywhere internally and becomes local exactly once, where a human reads it. Every additional conversion point is a place a zone can be lost or applied twice.

Where the conversion lives
Converted wherever convenient
# each layer decides for itself
db:      naive timestamp column, host zone at write time
api:     serialises without an offset
worker:  os.environ.get("TZ") or system default
cron:    schedule in business local time
logs:    host local time, no offset
report:  converts again, "to be safe"
UTC inside, local at the edge
# one boundary, everything behind it is instants
db:      zone-aware timestamp, always UTC
api:     serialises instants with an explicit offset
worker:  container TZ=UTC; zone-aware types only
cron:    schedule expressed in UTC
logs:    UTC with explicit offset in every line
report:  ONE render function converts to the viewer zone,
         using the zone id, at display time

On the left, every hop can lose or reapply a zone, and the errors compound into stored data where they are permanent. On the right there is a single place to review, a single place to test, and a single place where a daylight saving transition can affect anything — and it affects only what a human reads, never what is stored or compared.

Where the zone gets lost

These are the specific hops where a correct UTC value becomes an incorrect local one. Each is mundane, which is why they survive review.

Zone loss by layer
TriggerSymptomCauseResponse
Column declared as a naive timestamp typeRows from different hosts are an hour apart for the same eventEach writer applied its own host zoneUse a zone-aware type; migrate with expand and backfill (Expand, Migrate, Contract)
Base image changedTimes shift with no application changeThe host timezone default changed underneath a library that uses itPin TZ=UTC in the image and assert it at startup (Validate at Startup, Fail Clearly)
JSON serialisation between servicesReceiver interprets a bare timestamp in its own zoneThe offset was dropped in transitRequire an explicit offset in the schema and reject values without one
Report or export built by a different teamTotals differ from the dashboard by one day at the boundaryTwo different definitions of when the day startsDefine business boundaries once, with an explicit zone, and share the definition
Zone database not updatedLocal times wrong after a country changes its rulesStale zone data in the runtime or imageTreat zone data as a dependency with an update cadence (Dependency Management)
Duration computed from stored local timesNegative or inflated durations twice a yearSubtraction across a transitionCompute durations from instants only, never from wall times

How to do it properly

Most important first.

  • Store instants in UTC, with an explicit type that carries the zone or offset rather than a naive local timestamp.
  • Log in UTC, in a format that includes the offset explicitly, so a log line is unambiguous when read out of context.
  • Run schedulers, cron and job timers in UTC, and translate to local time only for display (Timezone and DST Failures).
  • Set server and container timezones to UTC so that anything that ignores your application settings still produces UTC.
  • Convert at the display edge only, using the viewer's zone, and keep exactly one place in the code that does it.
  • For future human appointments, store the local wall time plus the zone identifier, and compute the instant at use — not at write.
  • Keep zone data updated as a dependency. It changes with political decisions and an outdated copy produces wrong local times (Dependency Management).
  • Put UTC on operator dashboards, or show both — an incident timeline that mixes zones costs minutes exactly when they are expensive (Reconstructing What Actually Happened).

How much can this affect

Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.

Blast radius if this is wrongEveryone
One testEveryone
What contains it

A single conversion point at the display edge contains it. Scattered conversions do not — the damage is spread across stored data and only becomes visible later (Partial and Logical Data Recovery).

What can go wrong

Failure modes, including of the mitigation
  • A naive timestamp column stores whatever zone the writing process happened to have, so records from different hosts are incomparable.
  • A library defaults to the host timezone, and a container image change alters behaviour with no code change.
  • Durations computed across a transition produce a negative or inflated value that propagates into billing or metrics.
  • Zone data is stale in a base image, so times are correct until a country changes its rules.
  • Serialisation drops the offset between services, and the receiver applies its own default.
Misreads this invites
  • "We only operate in one country, so local time is fine." Zone rules still change, the repeated hour still exists, and your third-party APIs and cloud provider are already reporting in UTC.
  • "Storing an offset is the same as storing a zone." An offset is a snapshot of a rule. Future local times need the rule itself.
  • "UTC and GMT are interchangeable." They are close enough for most application purposes and are not the same definition; use UTC and let the platform handle leap seconds (Clock Synchronisation).
  • "The database handles timezones." Some types store an instant and some store a naive value that merely looks like a time. Check which one your column actually is.

Operating it

Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.

How you know it worked
  • Logs from every service carry an explicit offset and agree with each other during a correlation exercise.
  • Timestamp columns use a zone-aware type, verified by inspecting the schema rather than by convention.
  • Scheduler configuration is expressed in UTC and reviewed as such.
  • An incident timeline can be assembled from multiple services with no manual conversion step (The Debugging Timeline).
How you get back
  • Migrating stored timestamps to UTC is a data migration with the same discipline as any other: expand with a new zone-aware column, dual-write, backfill with an assumed source zone, verify, then contract (Expand, Migrate, Contract).
  • The backfill is the risky part, because it requires assuming which zone historical naive values were written in — and that assumption may be wrong for some rows.
  • Reverting a display-layer change is trivial; reverting a storage change after new data has arrived is not. Keep the conversion at the edges so most changes stay reversible.
What to automate, and what stays human
  • Automate: linting for naive timestamps and for host-timezone-dependent calls, a CI check that container timezones are UTC, and zone data updates in base images.
  • Automate the display conversion in one shared component, so no individual feature has to make the decision.
  • Keep human: choosing the zone semantics for a business rule — a billing day, a cutoff, a retention window — because that is a product decision about which humans the boundary belongs to.
What this costs
  • UTC everywhere is harder to read for humans on the operations side, and every dashboard needs a conversion or a dual display.
  • Storing local time plus a zone for future events is correct and more work than storing an instant, and it is the only approach that survives a rule change.

Where this applies

This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.

  • GENERALUTC for machine timelines is universal practice across stacks and providers. The only genuine exceptions are user-facing display and business rules that are defined in a specific locality.
  • DATABASE-SPECIFICTimestamp types differ in a way that matters: some store an instant and normalise to UTC, some store a naive value with no zone at all, and the names are misleadingly similar between engines. Read your engine's documentation for the specific type rather than assuming the behaviour transfers.
  • TOOL-SPECIFICLanguage and library defaults vary — some default to the host zone, some to UTC, some depend on an environment variable. A container image or base image change can therefore alter time behaviour with no application change.

Where the depth lives

This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.

Observability & Performanceincident-timeline
Securityaudit-logs
Domains that do not exist yet
  • Distributed Systems — clocks as an ordering mechanism: logical clocks, causality and why timestamps from different machines cannot establish happened-before. This lesson is about human-readable time, not about ordering.