timestamp vs timestamptz: which one your columns should be
The names lie a little. timestamp (without time zone) stores a
wall-clock reading: "2026-07-16 14:30:00", no indication of where on the planet
that was true. timestamptz stores an instant: the moment itself,
normalized to UTC internally, converted to your session's time zone on the way
out. Neither type stores a time zone. The difference is whether the value means
anything without one.
What each type actually does
SET TIME ZONE 'America/Sao_Paulo';
CREATE TABLE demo (
plain TIMESTAMP,
withtz TIMESTAMPTZ
);
INSERT INTO demo VALUES (now(), now());
SET TIME ZONE 'UTC';
SELECT * FROM demo;
-- plain | withtz
-- 2026-07-16 11:30:00 | 2026-07-16 14:30:00+00
The timestamptz column answered correctly in both sessions: same
instant, displayed for the current zone. The timestamp column just
repeated what it was given. It recorded São Paulo wall-clock time and now
presents it to a UTC session as if it were UTC. Nothing errors. The data is
quietly wrong by three hours.
This is why the failure mode is so common: with one server, one zone and no
DST transition, both types behave identically. The bug waits for the second
server, the container with TZ=UTC, the reporting job in another
region, or the clocks going back one October morning, when a
timestamp column replays the same hour twice.
The default should be timestamptz
For anything that answers "when did this happen", created_at,
updated_at, paid_at, event times, audit trails, you
want the instant, so you want timestamptz:
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
total NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Storage cost is identical (8 bytes each), so there is no efficiency argument
for the plain version. The legitimate uses of timestamp are narrow:
a future local-time commitment ("the store opens at 09:00" wherever the store
is), where the wall-clock reading really is the datum and converting it would be
wrong. Those columns usually deserve a companion column with the IANA zone name
('America/Sao_Paulo'), because a bare local time still can't be
placed on a timeline without one.
The session TimeZone gotcha
timestamptz converts on input and output using the session's
TimeZone setting. Two things follow. First, what you see in
psql depends on who's asking; the stored instant is the same.
Second, casting between the two types silently uses the session zone:
some_timestamp::timestamptz means "interpret this wall-clock value
in the current session's zone", which may or may not be what the writer of the
row intended. If you're migrating a legacy timestamp column, do the
cast explicitly with the zone the data was actually written in:
ALTER TABLE orders
ALTER COLUMN created_at TYPE timestamptz
USING created_at AT TIME ZONE 'America/Sao_Paulo';
How it maps to Java
The JDBC and JPA side mirrors the semantic split: timestamptz
maps to OffsetDateTime (or Instant with a converter),
timestamp maps to LocalDateTime. If you find yourself
storing LocalDateTime.now() into an audit column, that's the same
bug in application clothing: a reading with no zone. In TypeScript both arrive
as strings; the timestamptz one carries an offset
(2026-07-16T14:30:00+00:00) and parses unambiguously with
new Date(), the plain one doesn't.
The rule of thumb
- "When did X happen" ->
timestamptz. Always. - "What does the clock on the wall say there" ->
timestampplus a zone-name column. - Not sure ->
timestamptz. Being wrong in this direction is recoverable; the reverse means guessing zones on old data.