TIMESTAMPTZ is almost always the right choice, and its name is a lie that trips up every candidate. Despite "with time zone," it does not store a zone. It stores a single absolute instant — internally UTC — and converts to/from your session's TimeZone on the way in and out. Naive TIMESTAMP stores no offset at all, so the same literal means different instants on different servers.
What each type actually stores
TIMESTAMP(without time zone) — wall-clock digits with no offset.'2026-06-02 14:00'is just those numbers; Postgres has no idea which instant they refer to. Two servers in different zones will disagree about what it means.TIMESTAMPTZ(with time zone) — an absolute instant. On input, the value is converted from the session zone to UTC and that UTC instant is stored. On output, it's rendered in the session zone.
SET TimeZone = 'America/New_York';
SELECT '2026-06-02 12:00'::timestamptz; -- 2026-06-02 12:00:00-04 (stored as 16:00 UTC)
SET TimeZone = 'UTC';
SELECT '2026-06-02 12:00'::timestamptz; -- now displayed 2026-06-02 16:00:00+00
Same stored instant, displayed correctly for each session. With plain TIMESTAMP the digits would never change — and that's the bug.
Why TIMESTAMPTZ wins
- It's an instant, which is what you almost always mean. "When was this order placed?" is one moment in time, the same for everyone.
- DST-safe. Naive
TIMESTAMParithmetic across a DST boundary silently produces wrong durations;TIMESTAMPTZis anchored to UTC so intervals are correct. - Server/zone independent. Move the database or run app servers in multiple regions and the values still mean the same instant.
now()andCURRENT_TIMESTAMPreturnTIMESTAMPTZ— usingTIMESTAMPcolumns forces a lossy implicit cast.
When plain TIMESTAMP is correct
Rarely: when you mean a wall-clock value detached from any instant — a recurring "store opens at 09:00 local" or a birthday where the zone is irrelevant. Even then, many designs store the local time plus a separate zone column. If you're unsure, you want TIMESTAMPTZ.