timestamptz and tzdata: Avoid Shifted PostgreSQL Timestamps

timestamptz and tzdata: Avoid Shifted PostgreSQL Timestamps

PostgreSQL stores every timestamptz value as UTC and converts it to the session's time zone only at display time. That design is correct and elegant — until a tzdata update changes the offset rules for a region, at which point timestamps inserted under the old rules can render at a different local time than the day they were recorded.

Purpose and Overview

timestamp with time zone, almost always written timestamptz, is the type most applications should use for points in time. The name is slightly misleading: the column does not store a time zone. It stores a single absolute instant as UTC. When you insert a value with an explicit offset, PostgreSQL converts it to UTC for storage and discards the original zone; when you read it back, it converts from UTC to whatever the session's TimeZone parameter is set to. Two clients in different zones see the same instant rendered in their own local time, and that is exactly the behavior you want.

The subtlety that bites operators lives in the conversion step. PostgreSQL relies on the IANA time zone database — the tzdata package — to know each named zone's offset rules, including historical and future transitions. Those rules are not static. Governments change daylight-saving policy, redefine offsets, or abolish DST entirely, and the tzdata project ships updates to track them. When your server picks up a new tzdata, the rules used to convert UTC to local time change with it.

For most data this is invisible and beneficial. But if an application stored a future local appointment by converting it to UTC under the old rules, and the rules for that zone later change, the stored UTC instant now renders at a different wall-clock time than the user originally chose. The instant did not move; the rule that maps it to local time did. Recognizing this failure mode — and auditing for it — is the difference between a quiet data-quality problem and a stream of confused user reports.

Sample Code

 1-- Confirm storage is UTC and rendering depends on the session zone
 2SHOW TimeZone;
 3
 4SET TimeZone = 'America/Vancouver';
 5SELECT '2026-12-15 09:00:00-08'::timestamptz AS as_vancouver;
 6
 7SET TimeZone = 'UTC';
 8SELECT '2026-12-15 09:00:00-08'::timestamptz AS as_utc;
 9
10-- Probe whether a given zone's offset matches your expectation
11SELECT name, utc_offset, is_dst
12FROM pg_timezone_names
13WHERE name = 'America/Vancouver';

Notes: Runs on any supported PostgreSQL version; pg_timezone_names has been available for many major versions. The two SELECT statements return the same underlying instant displayed in two zones — proof that storage is UTC and rendering is per-session. The utc_offset reported by pg_timezone_names is computed against the current timestamp using the installed tzdata, so it reflects whatever rules the server currently holds.

Code Breakdown

The snippet demonstrates the storage-versus-rendering split and gives a way to inspect the rules currently in force.

Showing and Setting the Session Zone

1SHOW TimeZone;
2SET TimeZone = 'America/Vancouver';

TimeZone is a per-session client parameter. It controls how timestamptz values are displayed and how zone-less input is interpreted, but it has no effect on what is physically stored. Changing it changes the lens, not the data — which is why the same column can look different to two sessions without any update having occurred.

Rendering the Same Instant Two Ways

1SELECT '2026-12-15 09:00:00-08'::timestamptz;  -- under different TimeZone settings

Casting a literal with an explicit -08 offset fixes the absolute instant. Displaying it under America/Vancouver versus UTC produces two different wall-clock strings for one identical stored value. This is the mechanism behind every "the time looks wrong" report: the stored instant is constant, but the rendering rule varies.

Inspecting Zone Rules

1SELECT name, utc_offset, is_dst FROM pg_timezone_names WHERE name = '...';

pg_timezone_names exposes the zone definitions the server currently knows, computed against the current moment. Querying a specific zone's utc_offset is a quick way to confirm whether the installed tzdata agrees with your expectation — and, run before and after a package update, to detect that the rules changed.

Key Time Zone Concepts

UTC Storage, Per-Session Rendering

The foundational fact: timestamptz is stored as UTC and converted on display using the session's TimeZone. Internalizing this dissolves most timestamp confusion. The data is an absolute instant; everything zone-related happens at the boundary, on input and on output.

The Role of tzdata

The IANA database supplies the offset and DST rules for every named zone, including historical transitions needed to interpret past timestamps correctly. PostgreSQL reads these rules — either from a copy bundled with the server or from the operating system's tzdata, depending on how it was built — so an OS or server tzdata update changes the rules the database applies.

Where the Shift Happens

A stored UTC instant never changes on its own. What changes is the mapping from local intent to UTC. If a future appointment was converted to UTC using a rule that a later tzdata revises, the original UTC value no longer corresponds to the local time the user picked. Past, already-occurred timestamps are generally safe; the risk concentrates on future-dated values stored before a relevant rule change.

pg_timezone_names as the Source of Truth

Because this view computes offsets from the live tzdata, it is the authoritative way to ask "what does this server currently believe about this zone." Comparing its output across a maintenance window surfaces exactly which zones a tzdata update altered.

Practical Applications

Auditing for Affected Rows

After a tzdata update touches a zone your application uses, identify future-dated timestamptz rows that were inserted before the update and depend on that zone. Those are the candidates for re-evaluation, since their UTC values were computed under superseded rules.

Storing Local Intent Explicitly

When an application genuinely needs to preserve a user's chosen local time across rule changes — a recurring 9:00 a.m. appointment, say — store the local time and the zone name separately and compute the UTC instant on read. That way a tzdata change re-derives the correct UTC rather than leaving a stale one frozen in the column.

Validating in a Staging Copy First

Before applying a tzdata update to production, apply it to a staging copy and re-run the offset probes against the zones you care about. Confirm the new offsets match policy expectations, and measure the blast radius on future-dated data, before the change reaches live systems.

Standardizing the Session Zone

Pin a known TimeZone (often UTC) for batch jobs and reports so output does not silently depend on whichever session default a connection inherited. Consistent rendering removes a whole class of "the numbers differ between runs" confusion.

Version Compatibility

The timestamptz storage model — UTC internally, converted via the session TimeZone — has been stable across all currently supported PostgreSQL versions, 12 through 18 and well before. The pg_timezone_names and pg_timezone_abbrevs views have likewise been available for many major versions, so the audit techniques here apply broadly.

What varies is not the PostgreSQL version but the tzdata version the server uses, and that depends on how PostgreSQL was packaged. Some builds use a bundled copy of the time zone database; others link against the operating system's tzdata. The distinction matters because it determines what updating the rules actually requires — a PostgreSQL minor upgrade in the bundled case, or an OS package update in the system case. Either way, the rule set, not the major version, is what governs how UTC renders to local time.

Best Practices

  • Default to timestamptz — use timestamp with time zone for instants; reserve plain timestamp for genuinely zone-less wall-clock values.
  • Treat tzdata as a tracked dependency — know whether your build uses bundled or OS tzdata, and review zone-rule changes the way you would any dependency update.
  • Store local intent separately when it must survive rule changes — keep local time plus zone name and derive UTC on read for future-dated, policy-bound timestamps.
  • Probe offsets before and after updates — use pg_timezone_names to confirm what changed and to scope the impact before promoting a tzdata update.
  • Pin the session zone for batch work — set TimeZone explicitly in jobs and reports so rendering never depends on an inherited default.

References

Posts in this series