Skip to Content
Store Timestamps With Time Zone Correctly
Intermediate
0 likes
Tutorials

Store Timestamps With Time Zone Correctly

Choose timestamptz for a moment in time and see what the session time zone and AT TIME ZONE change. After this, the same moment shows correctly for every reader.

Skip ahead: If you already know why timestamptz is the right type and only want the display and conversion rules, skip to "Step 3: the same moment, shown in other zones", then continue.

A column called created_at looks harmless until two servers, or two offices, disagree about what time it holds. The usual cause is the type. A plain timestamp stores a date and a clock reading with no zone. A timestamptz stores a moment in time. For anything that happened, use the second one. This page shows the difference with real values, and one exception at the end.

Step 1: two columns, one value

SET TIME ZONE 'UTC';
CREATE TABLE meetings (
  meeting_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  plain_ts   timestamp,
  moment     timestamptz
);
INSERT INTO meetings (plain_ts, moment) VALUES ('2026-10-20 09:00+08', '2026-10-20 09:00+08');
SELECT plain_ts, moment FROM meetings;
SET
CREATE TABLE
INSERT 0 1
      plain_ts       |         moment
---------------------+------------------------
 2026-10-20 09:00:00 | 2026-10-20 01:00:00+00
(1 row)

The same text, 09:00+08, went into both columns. The timestamp column ignored the +08 and kept 09:00. The timestamptz column used it: 09:00 in Manila is 01:00 in UTC, and that is what it stored and what a UTC session shows. The plain column now holds a clock reading, and nothing says whose clock it was.

Step 2: the display follows the session

Change the session time zone and read both columns again.

SET TIME ZONE 'Asia/Manila';
SELECT plain_ts, moment FROM meetings;
SET
      plain_ts       |         moment
---------------------+------------------------
 2026-10-20 09:00:00 | 2026-10-20 09:00:00+08
(1 row)

The timestamp value did not move. The timestamptz value is the same moment as before, shown on a Manila clock as 09:00 with a +08 offset. Nothing was changed in the table. Only the display moved.

Step 3: the same moment, shown in other zones

AT TIME ZONE converts a timestamptz to the clock reading in a zone you name, so the answer does not depend on the session.

SELECT moment AT TIME ZONE 'Asia/Manila'      AS manila,
       moment AT TIME ZONE 'America/New_York' AS new_york,
       moment AT TIME ZONE 'UTC'              AS utc
FROM meetings;
       manila        |      new_york       |         utc
---------------------+---------------------+---------------------
 2026-10-20 09:00:00 | 2026-10-19 21:00:00 | 2026-10-20 01:00:00
(1 row)

Each column is a plain timestamp with the clock reading in that zone. Manila is 9 am, New York is 9 pm the evening before, and UTC is 1 am. Use zone names such as Asia/Manila, not fixed offsets such as +08, because names know about daylight saving.

To go the other way, from a clock reading to a moment, use AT TIME ZONE on a plain timestamp: "this reading is in New York".

SELECT TIMESTAMP '2026-10-20 09:00' AT TIME ZONE 'America/New_York' AS moment;
         moment
------------------------
 2026-10-20 21:00:00+08
(1 row)

The session is still on Asia/Manila here, so the moment is shown there: 9 am in New York is 9 pm in Manila.

Step 4: store a moment, not a reading

When you insert a value without an offset, PostgreSQL reads it in the session's zone. The same literal means different moments in different sessions.

SET TIME ZONE 'UTC';
INSERT INTO meetings (moment) VALUES ('2026-10-20 09:00');
SET TIME ZONE 'Asia/Manila';
INSERT INTO meetings (moment) VALUES ('2026-10-20 09:00');
SELECT meeting_id, moment AT TIME ZONE 'UTC' AS utc FROM meetings WHERE plain_ts IS NULL ORDER BY meeting_id;
SET
INSERT 0 1
SET
INSERT 0 1
 meeting_id |         utc
------------+---------------------
          2 | 2026-10-20 09:00:00
          3 | 2026-10-20 01:00:00
(2 rows)

Two inserts of "09:00" are eight hours apart. Always give the offset or the zone when you insert, for example '2026-10-20 09:00+08' or '2026-10-20 09:00 Asia/Manila', and let the application send explicit values.

The exception: a clock time that must stay local

"Every Monday at 9 am in Manila" is a rule about a wall clock, not a moment. If the zone's offset changes, the moment changes with it. Store the wall-clock time and the zone name in separate columns, and convert when you need the moment.

CREATE TABLE weekly_events (
  title     text NOT NULL,
  local_at  timestamp NOT NULL,
  zone_name text NOT NULL
);
INSERT INTO weekly_events VALUES ('Stand-up', '2026-10-19 09:00', 'Asia/Manila');
SELECT title, local_at AT TIME ZONE zone_name AS moment FROM weekly_events;
CREATE TABLE
INSERT 0 1
  title   |         moment
----------+------------------------
 Stand-up | 2026-10-19 09:00:00+08
(1 row)

⚠ Warning: Do not store a moment in a plain timestamp and promise yourself it is always UTC. The promise lives in your head and in nobody else's. Use timestamptz.

Related course: SQL Intermediate.

About

Choose timestamptz for a moment in time and see what the session time zone and AT TIME ZONE change. After this, the same moment shows correctly for every reader.

Responsible
Christopher R
Last Update
10/06/2026
Reading Time
5 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.