DEV Community

Sudeep Hazra
Sudeep Hazra

Posted on

UTC is not a data contract. Period.

Someone in the October r/dataengineering discussion thread described losing three days to a timezone mismatch. The production dashboard looked correct for the first two hours each morning, then drifted by four or five hours.

That is painfully believable. Timestamp bugs often survive because every value looks like a valid date. The pipeline runs, rows arrive, and the dashboard renders. Only the meaning moved.

The usual prevention advice is "store everything in UTC." I would store event instants in UTC too, but that sentence is not a complete data contract.

UTC can identify when something happened. It cannot always tell us which local business day it belonged to, which timezone rules were applied, or whether 2026-11-01 01:30 referred to the first or second occurrence of that wall-clock time.

We need to model time according to the question the data must answer.

One timestamp can carry several meanings

Consider a restaurant booking recorded as 2026-11-01 01:30.

That value might mean:

  • an instant when the booking was created
  • a local reservation time chosen by a customer
  • a daily reporting boundary
  • a recurring schedule such as "every Sunday at 01:30"
  • text copied from a source that never supplied a timezone

Those are different data types even if the source puts all of them into a column called timestamp.

I would distinguish at least four concepts:

Concept Example Useful representation
Instant Payment received UTC timestamp
Local wall-clock time Store opens at 09:00 Local date/time plus timezone name
Business date Trading day or service date Explicit date derived by an owned rule
Observed offset Source sent +05:30 Offset retained as source evidence

An offset is not a timezone. +01:00 tells us the relationship to UTC for one value. It does not carry the historical and future daylight-saving rules of Europe/London or another named zone.

IANA published tzdata 2026e on September 30. Timezone rules are maintained data, not a law of nature embedded permanently in every runtime. IANA notes that downstream distributors may wait for a timezone database release before updating consumer software. Two services can therefore disagree even when both use a legitimate timezone name.

Database types do not rescue an ambiguous source

Type names are not consistent enough to infer intent by sight.

PostgreSQL documents that plain timestamp means timestamp without time zone. A value with a timezone indicator can be silently interpreted according to the target type and session settings. PostgreSQL's timestamptz represents an instant and displays it in the current session timezone. It does not preserve the original timezone name as a business attribute.

Snowflake has three timestamp variants with different behaviour. Its date and time documentation says:

  • TIMESTAMP_NTZ stores wall-clock time without timezone consideration.
  • TIMESTAMP_LTZ stores UTC internally but performs operations in the session timezone.
  • TIMESTAMP_TZ stores UTC with an associated offset, not the original timezone name.

Snowflake also warns that adding six months to a TIMESTAMP_TZ value can retain the original offset even when daylight-saving rules would give the named location a different offset in the new month. The zone name is no longer there to recalculate it.

None of these behaviours is inherently wrong. The mistake is expecting a database type to recover context the source did not provide.

If the source sends 2026-10-10 09:00:00, the ingestion layer needs a declared rule. Is that UTC? The source system's configured zone? The user's zone? The location attached to the record? Guessing from the server timezone turns deployment configuration into business logic.

Put time semantics into the contract

For an event, I would prefer a payload shaped like this:

{
  "event_id": "evt_7192",
  "occurred_at": "2026-10-10T03:30:00Z",
  "source_local_datetime": "2026-10-10T09:00:00",
  "source_timezone": "Asia/Kolkata",
  "source_utc_offset": "+05:30",
  "recorded_at": "2026-10-10T03:30:04Z"
}
Enter fullscreen mode Exit fullscreen mode

Not every event needs every field. Keep the values when they answer different questions.

occurred_at supports global ordering and elapsed-time calculations. source_local_datetime preserves what the source or user saw. source_timezone allows the local value to be interpreted using named rules. source_utc_offset records what the producer applied at that moment. recorded_at helps measure ingestion delay.

When the event only represents a machine action and no local-calendar meaning exists, the UTC instant may be enough. When the event is a future appointment, store the local date/time and timezone name because the offset can change before the appointment occurs.

I would make the contract explicit about these questions:

  • Which fields are instants and which are wall-clock values?
  • Is a timezone name required, and where does it come from?
  • Is the source offset preserved?
  • Which timezone database version did the producer use?
  • What happens when a local time is ambiguous or does not exist?
  • Who owns the definition of business date?
  • Are late events assigned by occurrence time or arrival time?

"All timestamps are UTC" answers only part of the first question.

Business dates deserve their own rule

Many reporting errors happen after ingestion, when an instant becomes a date.

Imagine a payment at 2026-10-10T23:30:00Z. In London, Kolkata, and Los Angeles it can belong to different local dates. If finance reports by the merchant's trading day while support reports by the customer's local day, both teams may be correct and still disagree.

Do not let each dashboard rediscover the rule with a different call to DATE().

Model the derived date and its ownership:

occurred_at_utc
      +
merchant_timezone
      +
trading_day_cutoff
      ↓
merchant_business_date
Enter fullscreen mode Exit fullscreen mode

The cutoff may not be midnight. Hotels, transport operators, markets, and overnight venues often define an operating day around a business boundary. That is domain logic, not timestamp formatting.

Keep the original instant so the date can be recomputed if the rule changes. If reports need to remain historically reproducible, version the rule or materialize the assigned business date with the rule version used.

Test the conversion path, not just the parser

A parser test proves that the string is syntactically valid. It does not prove that the pipeline preserved the same instant.

I would build a small set of timezone fixtures and run them through every important boundary:

source payload
    ↓
application parser
    ↓
message or file serialization
    ↓
warehouse column
    ↓
transformation model
    ↓
reporting query
Enter fullscreen mode Exit fullscreen mode

The fixture set should include:

  • a normal UTC value
  • a non-whole-hour offset such as +05:30
  • the hour before and after a daylight-saving transition
  • an ambiguous local time during a fall-back transition
  • a nonexistent local time during a spring-forward transition
  • a historical date whose timezone rules differ from today
  • a future scheduled time
  • a source value with no timezone, if the contract permits one

For every fixture, record the expected instant, local rendering, business date, and rejection behaviour. These values should come from an agreed reference, not from whatever the current test runtime happens to return.

Snowflake's CONVERT_TIMEZONE documentation makes one source of accidental behaviour clear. A TIMESTAMP_NTZ has no timezone, so the two-argument form interprets it using the current session timezone. A warehouse task and an analyst session can therefore produce different answers if their session parameters differ.

Set session timezone configuration explicitly in pipelines. Then test it.

Add invariants that expose drift

The first useful alert is not "the timestamp column is non-null." A wrong timestamp is usually non-null.

I would monitor invariants such as:

  • the difference between occurred_at and recorded_at stays within an expected range
  • source offsets match the named timezone for the supplied local date/time
  • no new timezone names appear without contract approval
  • parsing failures and ambiguous local times remain visible
  • event counts by UTC hour and business hour remain within expected patterns
  • source and target daily totals reconcile across the timezone boundary

That last check catches the classic failure where no rows disappear but several move into the previous or next day.

Compare closed windows after an allowed lateness period. A live source count and a live warehouse count can differ because events are still in flight. Once the window closes, reconcile both total rows and the distribution around the day boundary.

If a pipeline begins drifting only after a particular hour, compare its session timezone, runtime timezone database, source offset, and conversion function. The shape of the error often points toward the layer that changed.

Treat timezone data like a dependency

Timezone rules change. That means the database version used by the operating system, language runtime, database engine, and warehouse is part of the pipeline's dependency set.

I would record those versions during deployment and expose them in diagnostics. When IANA publishes an update, test the zones relevant to the business before rolling it through every service. The goal is not to freeze old rules forever. It is to know when a conversion result changed and whether historical values need to remain reproducible.

For future schedules, recalculate derived UTC instants when the governing timezone data changes. For completed events, preserve the original instant and source evidence. Those are different migration policies because future intent and historical fact are different things.

Store the meaning, not only the value

UTC is the right representation for many event instants. I would still use it as the common ordering and transport form.

I would not ask it to carry local business meaning that was discarded earlier in the pipeline.

Name the concepts, preserve the source context that matters, define the conversion owner, and test the full route from producer to report. Then a timezone database update becomes a dependency change we can evaluate instead of a mystery that moves five hours of revenue into the wrong day.

A timestamp is easy to store. Time is part of the domain model.

Top comments (0)