Topic 08

Time — timestamptz and the Time Zone Trap

Date and Time

timestamptz does not store a time zone. It stores one moment, as microseconds relative to a fixed epoch in UTC, and it converts on the way in and on the way out using the session's TimeZone setting. That is almost always what you want. timestamp stores wall-clock digits with no notion of which clock produced them — half the ORMs create one by default, and every migration written in a hurry inherits it.

Both types are 8 bytes with microsecond resolution and a range from 4713 BC to 294276 AD. The storage is identical; the difference is entirely in what the value means, and therefore in what the database is allowed to do on your behalf. Nadia's audit finds orders.placed_at, customers.created_at and delivery_events.occurred_at all declared timestamp, with the application writing UTC into them as a matter of team convention.

What Each Type Stores

A timestamptz input is interpreted in the session's zone, or in the offset written into the literal, converted to UTC, and stored. The originally stated or assumed time zone is not retained anywhere. On the way out the stored moment is rendered in whatever zone the reading session asks for.

One stored moment, two sessions, two renderings
SET TimeZone = 'UTC';
SELECT placed_at FROM orders WHERE id = 981274461;
 2026-08-13 06:12:44.318+00

SET TimeZone = 'Europe/Berlin';
SELECT placed_at FROM orders WHERE id = 981274461;
 2026-08-13 08:12:44.318+02

Nothing on disk changed between those two queries. One row, one instant, two readings of the same instant — and the offset in the output is a rendering artefact, not a stored field. That is the property that makes timestamptz safe to compare, sort, index and aggregate without anyone agreeing on a convention first.

One moment stored, rendered per session
Inputsession zone, or the offset in the literal
Converted to UTCthe stated zone is not kept
Stored as one moment8 bytes, microsecond resolution
Rendered per sessionUTC here, +02 there, same instant

A timestamp column keeps the digits it was handed and can never say what moment they denote. Nothing rejects a value of the right shape, so the reading 02:30:00 written on 29 March 2026 is stored, indexed and returned like any other — and in Europe/Berlin the clock jumps straight from 02:00 to 03:00 that morning, so no such moment exists.

UTC in a timestamp Is Not the Same Thing

Cartwheel's team will say, correctly, that they only ever write UTC into placed_at. The bytes are therefore right. What is missing is the declaration: the type no longer states the convention, so every comparison with now(), every AT TIME ZONE, every reporting client and every future service has to re-apply a rule the database is not enforcing. It holds until one component forgets, and the result is not a crash but a set of rows two hours out.

Converting 40 million rows without a rewrite
SET TimeZone = 'UTC';

ALTER TABLE orders
  ALTER COLUMN placed_at TYPE timestamptz;

Since 12, changing between timestamp and timestamptz avoids the table rewrite when the session time zone is UTC, because in UTC the two types are binary compatible. The statement still takes an ACCESS EXCLUSIVE lock, briefly, and finishes in milliseconds instead of rewriting 40 million rows.

AT TIME ZONE, in Both Directions

The same operator does two opposite jobs, and which one it does is decided by the type of its left operand. Applied to a timestamptz, it answers "what did the clock in that zone read at this moment" and returns a timestamp. Applied to a timestamp, it asserts "these digits were a clock reading in that zone" and returns the timestamptz for the moment they denote.

Both directions, one operator
-- moment  →  local wall clock  (timestamptz → timestamp)
SELECT placed_at AT TIME ZONE 'Europe/Berlin' FROM orders WHERE id = 981274461;
 2026-08-13 08:12:44.318

-- wall clock  →  moment  (timestamp → timestamptz)
SELECT timestamp '2026-08-13 08:12:44.318' AT TIME ZONE 'Europe/Berlin';
 2026-08-13 06:12:44.318+00

Reading those two lines as "the same conversion" is the source of most of the confusion around this operator. The analytics dashboard needs the first direction — grouping deliveries by the customer's local day means converting the stored moment to the customer's wall clock and truncating that, not casting the moment and hoping the server's setting happens to match. The type of the result is the tell: one direction loses the zone, the other supplies it.

One operator, two opposite jobs — the left operand decides which
timestamptz AT TIME ZONEreturns a timestamp
Asks what the clock in that zone read at this moment, and hands back digits. This is the direction the dashboard needs to group deliveries by the customer's local day.
timestamp AT TIME ZONEreturns a timestamptz
Asserts that these digits were a clock reading in that zone, and hands back the moment they denote. One direction loses the zone; the other supplies it.

The now() Family

Postgres has three different notions of "now" and they are not interchangeable. now() and CURRENT_TIMESTAMP return the start time of the current transaction and do not advance while it runs; the documentation calls this a feature, so that every modification inside one transaction bears the same timestamp. statement_timestamp() is the start of the current statement. clock_timestamp() is the real wall clock at the instant of the call, and it changes even within a single statement.

Three answers to the same question, ten seconds apart
BEGIN;
SELECT now(), clock_timestamp();
 2026-08-13 06:12:44+00 | 2026-08-13 06:12:44.002+00

-- ten seconds pass, still inside the same transaction
SELECT now(), clock_timestamp();
 2026-08-13 06:12:44+00 | 2026-08-13 06:12:54.117+00
COMMIT;

Timing a long transaction with now() therefore reports zero, every time, and clock_timestamp() is what the question "how long did that take" actually wants. The stability of now() is deliberate and Cartwheel depends on it: checkout writes orders.placed_at and the first row of delivery_events in one transaction, and both land on the identical instant because both called now(). Write those two stamps with clock_timestamp() and they differ by however long the checkout took.

Dates, Intervals, and the Delivery Window

date is 4 bytes at one-day resolution with no zone at all. interval is 16 bytes and stores three separate fields — months, days and microseconds — because a month is not a fixed number of days and, across a daylight-saving transition, a day is not a fixed number of hours. Keeping them apart is what lets Postgres get calendar arithmetic right.

"About a month" has two correct answers
SELECT date '2026-01-31' + interval '1 month';   -- 2026-02-28 00:00:00
SELECT date '2026-01-31' + interval '30 days';   -- 2026-03-02 00:00:00

Adding one month clamps to the last day that exists in the target month; adding thirty days counts days. Both are right, they differ by two days, and an application that computes either in Python is reimplementing a calendar that already ships with the database — and getting the daylight-saving cases wrong on the two weekends a year when the offsets move.

The predicate that cannot use an index, and the one that can
-- casts every one of 40 million rows, then compares
WHERE placed_at::date = DATE '2026-08-13'

-- compares the raw column, so the index on placed_at is usable
WHERE placed_at >= TIMESTAMPTZ '2026-08-13 00:00+02'
  AND placed_at <  TIMESTAMPTZ '2026-08-14 00:00+02'

The first form applies a function to the column, so the index on placed_at cannot answer it and Cartwheel sequential-scans the whole table for one day's orders. The half-open range compares the stored value directly and uses the index. Building an expression index to rescue the first form does not work either: casting a timestamptz to date depends on the session's TimeZone, which makes the expression stable rather than immutable, so the zone has to be written into the expression before it can be indexed at all.

Storing the Zone When the Zone Is Data

There is one honest use for timestamp, and it is narrower than people think. "The depot opens at 09:00 local time, whatever that turns out to be" is not a moment yet — if a government moves that zone's offset next year, the intended instant moves with it. Storing a timestamptz would freeze a decision nobody has made. The right shape is the local wall-clock timestamp plus the IANA zone name in its own column, with the instant computed at the point of use. Cartwheel's delivery windows are not that case. A customer's Saturday slot is a concrete pair of moments, and it is stored as a tstzrange of timestamptz bounds.

timestamptz vs timestamp

timestamptz — an unambiguous moment, converted per session on read and write. Use it for everything that happened: placed_at, occurred_at, created_at, and every audit column you will later have to correlate with a log line.

timestamp — digits with no clock attached. Use it only for a wall-clock rule that is not yet bound to a location, and always store the IANA zone name beside it so the moment can be computed later.

The review rule — default to timestamptz and treat every timestamp column in a schema review as a question that needs an answer in writing, not as a stylistic preference.

Common Mistakes
  • Letting the ORM create timestamp columns and relying on a team convention that they hold UTC — the first service deployed in another zone writes local digits into them, and no row records which rule applied.
  • Filtering with placed_at::date = '2026-08-13' — the cast runs per row, the index on placed_at becomes unusable, and one day's orders cost a scan of all 40 million.
  • Measuring elapsed time inside a transaction with now() — it returns the transaction start time, so every duration comes back as zero or as the same wrong constant.
  • Setting the server's TimeZone away from UTC to make one report look right — the conversion belongs at the session or the presentation layer, and a global change relocates the bug rather than removing it.
  • Fetching naive timestamps and doing the arithmetic in application code — every language ships its own reading of the tz database, and the disagreements only appear on the two weekends a year when the offsets move.
  • Storing a future local appointment as a timestamptz — the instant is frozen against today's rules, and a zone change moves every one of those appointments by an hour with nothing in the schema to show for it.
Best Practices
  • Make timestamptz the default for every column that records when something happened, and require a written justification for any timestamp that survives review.
  • Convert existing timestamp columns with the session set to UTC, so the change is a catalogue update rather than a full rewrite of the table.
  • Keep the server in UTC and convert at the edges with AT TIME ZONE or a per-session TimeZone, so no report depends on the cluster's default.
  • Write date filters as half-open ranges against the raw column, so the index stays usable and midnight belongs to exactly one of the two days.
  • Use interval arithmetic in SQL whenever the rule involves months or daylight saving, and reserve clock_timestamp() for measuring how long something took.
  • Store the IANA zone name alongside any local wall-clock time you keep, so the moment can be recomputed correctly after the zone's rules change.
Comparable toolsMySQL TIMESTAMP converts to UTC, DATETIME does notOracle TIMESTAMP WITH TIME ZONE really stores the zoneSQL Server datetimeoffset keeps a fixed offsetIANA tz database the rules underneath all of them

Knowledge Check

What does a timestamptz column physically store for each row?

  • A single moment normalized to UTC, with no zone kept alongside it
  • The moment plus the time zone the value was originally written in
  • Local wall-clock digits interpreted against the server's TimeZone setting
  • Wall-clock digits in one field and a numeric UTC offset in a second field

A team stores UTC values in a plain timestamp column and insists nothing is wrong. What is actually weaker about it?

  • The column loses sub-second precision, so ordering within a second is arbitrary
  • The type does not declare the convention, so nothing enforces it on any client
  • Timestamp columns cannot be indexed or compared with range predicates
  • Stored values shift whenever the server's TimeZone parameter is changed

Inside one transaction, two calls to now() ten seconds apart return the identical value. Why?

  • The planner caches the first result and reuses it for the rest of the transaction
  • now() returns the transaction start time, which is fixed for its whole life
  • The clock resolution is coarser than the gap between the two statements
  • The transaction's snapshot freezes every function's result until it commits

Why does WHERE placed_at::date = DATE '2026-08-13' scan all 40 million rows despite an index on placed_at?

  • The date literal has a different type from the column, so comparison is impossible
  • Equality predicates on timestamp columns cannot use a B-tree index
  • The cast transforms the column itself, so the index on the raw value does not apply
  • The planner estimates the scan as cheaper than 40 million index lookups

Which case genuinely justifies a timestamp column rather than timestamptz?

  • An audit column recording when a row was last modified by the application
  • Any column the application already guarantees it will write in UTC
  • A future local opening time whose zone rules could still change before it arrives
  • A high-volume event column where the smaller storage footprint matters

You got correct