Schemint

paste PostgreSQL or JSON, get your schema reviewed like a senior would

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

Schemint flags audit-style columns declared as plain TIMESTAMP (try it on a sample schema), before the type mapping hardens into the Entity and the migration.

Related