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 zonefor instants; reserve plaintimestampfor 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_namesto confirm what changed and to scope the impact before promoting atzdataupdate. - Pin the session zone for batch work — set
TimeZoneexplicitly in jobs and reports so rendering never depends on an inherited default.
References
- PostgreSQL Documentation — Date/Time Types — defines
timestamptz, UTC storage, per-session rendering, and reliance on the IANA database. - PostgreSQL Documentation — Client Connection Defaults (TimeZone) — the
TimeZoneparameter that controls display and zone-less interpretation. - PostgreSQL Documentation — pg_timezone_names — the view exposing recognized zones, their offsets, and DST status.
- Crunchy Data Blog — British Columbia, Time Zones, and Postgres — worked example of a tzdata change shifting stored timestamps and how to detect and handle it.
Posts in this series
- How Many Connections Can Your PostgreSQL Database Handle?
- PostgreSQL Backend Connections via pg_stat_database
- pg_blocking_pids — Find Blocking Queries in PostgreSQL
- List PostgreSQL Databases by Size with Access Check
- Assess PostgreSQL Database Sizes Quickly and Easily
- Unveiling Your PostgreSQL Server - A Diagnostic Powerhouse
- Keep Your PostgreSQL Database Clean, Identify Idle Connections
- Query the PostgreSQL Configuration
- pg_is_in_recovery — Monitor PostgreSQL Standby Status
- ALTER SEQUENCE RESTART WITH in PostgreSQL — Examples
- Monitor Running Queries in PostgreSQL using pg_stat_activity
- Monitor PostgreSQL Active Sessions with pg_stat_activity
- PostgreSQL Error Handling Settings via pg_settings
- PostgreSQL File Location Settings Query via pg_settings
- PostgreSQL Lock Management Settings via pg_settings
- PostgreSQL Logging Configuration Query via pg_settings
- Monitor PostgreSQL Memory Settings with pg_settings
- PostgreSQL Table Row Count Estimates with SQL
- List PostgreSQL Tables by Size with SQL
- PostgreSQL WAL Settings Query Guide
- log_parser_stats, log_planner_stats, log_executor_stats — PostgreSQL
- PostgreSQL SSL Settings Query Guide
- PostgreSQL Resource Settings Query Guide
- PostgreSQL Replication Settings Query Guide
- PostgreSQL Query Planning Settings Query Guide
- PostgreSQL Preset Options Settings Query Guide
- PostgreSQL Miscellaneous Settings Query Guide
- Count PostgreSQL Sessions by State with SQL
- Kill Idle PostgreSQL Sessions with SQL
- GRANT SELECT on All Tables in PostgreSQL — with Examples
- pg_stat_user_tables — Find Insert-Only Tables in PostgreSQL
- Detect Soft Delete Patterns in PostgreSQL
- List PostgreSQL Object Comments with SQL
- List Foreign Key Constraints in PostgreSQL
- List PostgreSQL Enum Types and Their Values with SQL
- List All Views in a PostgreSQL Database with SQL
- Find PostgreSQL Tables Without a Primary Key
- List PostgreSQL Partitioned Tables with SQL
- List All Schemas in Your PostgreSQL Database
- pg_stat_database — Query PostgreSQL Database Statistics
- List PostgreSQL Roles and Their Privileges
- Scrubbing Email PII in PostgreSQL for GDPR Compliance
- List Installed Extensions in PostgreSQL
- List Collations in Your PostgreSQL Database
- PostgreSQL Replica Identity for Logical Replication
- Monitor PostgreSQL Vacuum Progress with pg_stat_progress_vacuum
- Monitor PostgreSQL Wait Events Using pg_stat_activity
- Monitor PostgreSQL Replication Lag with pg_stat_replication
- List PostgreSQL Wait Events with the pg_wait_events View
- PostgreSQL Column-Level Permissions Audit Query
- List All PostgreSQL Triggers with Their State
- timestamptz and tzdata: Avoid Shifted PostgreSQL Timestamps
- Inspect PostgreSQL Sequences with the pg_sequences View