The guiding principle (the motto)
Keep UTC for as long as possible.
Change to a local timezone only when it is truly required.
This principle was not theoretical.
It emerged from a real incident — one that almost became a production bug, and only didn’t because of a fortunate configuration coincidence.
Why time zones are dangerous in ETL
Time-related issues are among the hardest problems in data engineering because:
- pipelines usually don’t fail
- data looks “reasonable” when inspected manually
- errors appear only during part of the year (DST)
- different tools show different values for the same data
This post tells the story of one such case and extracts the lessons that matter.
The situation we were in
We had an ETL pipeline with several stages:
- a source system exposing timestamps as
timestamp without time zone - a silver layer storing data as
timestamptz - a gold layer also using
timestamptz - a Pentaho pipeline performing timezone transformations
- an external API expecting timestamps in UTC (ISO 8601 with
Z)
Everything seemed consistent.
Even better, all validations during winter showed no issues at all.
What the source guaranteed (and what it didn’t)
The source system guaranteed the following:
“All timestamps are in UTC.”
However:
- the column type was
timestamp(no timezone) - the semantic meaning (UTC) was purely contractual
This is very common — and also very dangerous if handled implicitly.
What the pipeline was doing
Conceptually, the pipeline did this:
- Read a
timestampthat represents UTC - Convert it to
UTC - Convert it to
Europe/Lisbon - Store the result as
timestamptzin the silver layer - Propagate the same value to the gold layer
- Send the value to the API
At first glance, this seems fine.
In reality, it only worked because of luck.
The lucky coincidence (and why things “worked”)
Here is the critical part of the story.
- The Silver database server was configured with timezone
Europe/Lisbon - The Gold database server was also
Europe/Lisbon - The Pentaho runtime session was aligned with
Europe/Lisbon - The pipeline explicitly converted:
- first from UTC
- then to Europe/Lisbon
Because of this alignment:
- Lisbon wall time was interpreted correctly
- the cast to
timestamptzproduced the correct instant - clients in Lisbon saw the expected local hour
- clients in UTC saw the expected UTC hour
In other words:
the system was correct by coincidence, not by design.
Why this was fragile
The pipeline was correct because the runtime and database session timezones were aligned with the intention of the transformation.
In practice, that meant:
- the ETL step that produced a Lisbon wall time was interpreted consistently as Europe/Lisbon
- the value was stored as a
timestamptzrepresenting the correct instant - different clients (UTC vs Europe/Lisbon) only changed the display, not the stored instant
So the fragile part was not “someone running the pipeline from a different machine” in the abstract.
The fragile part was this:
Any implicit conversion that depends on the session timezone becomes unsafe if the session timezone changes.
This can happen when:
- the ETL tool starts new connections with a different
TimeZonesetting - a driver defaults differently in another environment
- a connection pool changes session parameters
- a DBA changes database defaults and the ETL does not explicitly set
TimeZone
What would actually break it
Only the parts of the pipeline that rely on implicit interpretation would break — for example:
- casting a
timestamp(no timezone) totimestamptzwithout explicitly stating which timezone thattimestamprepresents
If the ETL always uses explicit conversions like:
... AT TIME ZONE 'UTC'... AT TIME ZONE 'Europe/Lisbon'
then the result is deterministic and does not depend on where it runs.
The real conclusion
This is robust if and only if:
- the pipeline explicitly sets or controls the session timezone, or
- it avoids implicit casts and always anchors semantics with
AT TIME ZONE
The important lesson is not “don’t move servers”, it’s this:
Don’t let correctness depend on implicit session timezone behavior. Make conversions explicit.
How the problem was discovered
The issue surfaced when validating summer (DST) data.
In July:
- Lisbon is UTC+1
- UTC and local time diverge
We noticed that:
- values stored in the gold layer looked correct locally
- but values sent to the API were off by one hour
No errors.
No warnings.
Just subtly wrong data.
DST exposed what winter had hidden.
The missing step
The key realization was simple:
- internally, the system was working with Lisbon-local instants
- the API, however, explicitly required UTC
What was missing was a final, explicit conversion back to UTC before sending data to the API.
Once that step was added:
ts AT TIME ZONE 'UTC'
right before serialization, the values sent to the API became correct.
Why timestamptz didn’t save us
A common misconception is:
“If we use
timestamptz, timezones are handled.”
timestamptz:
- stores an instant
- does not store a timezone
- displays values using the client/session timezone
If the instant is wrong, timestamptz only makes the problem harder to see.
The tooling problem made it worse
Another major source of confusion was client tooling.
Different tools showed different values for the same row:
- ETL runtime
- database server
- DataGrip / DBeaver
- developer laptops
Each had its own timezone configuration.
This led to questions like:
- “Which value is correct?”
- “Did the pipeline shift the data?”
- “Is the database wrong?”
In reality:
- the data was the same
- only the representation differed
Without explicitly checking session and server timezones, debugging becomes guesswork.
The corrected mental model
We ended up with a clear separation:
1. Instants (system truth)
- stored as
timestamptz - normalized to UTC
- used for joins, ordering, comparisons, integrations
2. Representations (human view)
- derived from instants
- converted to local timezones
- never persisted as the primary truth
The corrected approach
Normalize early
If a source provides a UTC-semantic timestamp:
ts AT TIME ZONE 'UTC'
Do this once. Do it explicitly. Do it as early as possible.
Keep UTC internally
Persist only instants. Avoid storing local wall time as truth.
Convert at the edges
Convert only:
- for UI
- for reports
- for external systems with explicit requirements
Serialize explicitly for APIs
If an API requires ISO 8601 UTC:
to_char(ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
Never rely on implicit session settings.
The final lesson
This incident taught us something important:
The most dangerous bugs are the ones that work “by luck”.
The system behaved correctly:
- not because it was well designed
- but because all environments happened to align
That is not a guarantee — it is a risk.
Final takeaway
Timezones are not a formatting detail. They are part of your data model and system architecture.
And the principle that remains is simple:
Keep UTC for as long as possible. Convert only when you truly need to. Never rely on luck.
Because summer always comes.
- LinkedIn → https://www.linkedin.com/in/frederico-gago-5849281aa
- GitHub → https://github.com/fredericogago