Tutorial
timestamptz vs timestamp: The Difference That Breaks Production Timestamps
Why plain timestamp columns silently corrupt dates across timezones, and the exact fix—from Postgres column type to ORM config—for 2026 stacks.
A customer in Los Angeles completes a purchase at 23:59 on New Year’s Eve. Your reporting dashboard — run from a server, read by a finance team in New York — shows the sale happening in January. Nobody changed any code. Nobody fat-fingered a date. The column type was just wrong.
This is the bug that timestamptz vs timestamp searches are usually about. Here’s the direct answer, then the rest of the stack.
The direct answer
Use timestamptz for almost every datetime column you’ll ever create. Reserve plain timestamp for the narrow case where you genuinely want a timezone-less literal — and don’t use either for date-only values like birthdates.
But “use timestamptz” is not, by itself, a complete fix. It’s step one of a chain that also runs through your session timezone, your ORM, your Pydantic models, your JSON serialization, and the browser’s local clock. Break any link and you get the LA-sale-in-January bug anyway.
What each type actually stores
This is where most explanations get sloppy, so let’s be precise.
timestamp (without time zone) stores exactly what you give it — a literal date and time, no timezone attached, period. Postgres does not know, and does not ask, whether 2026-12-31 23:59:00 means Pacific time, Eastern time, or UTC. It’s just eight bytes representing a date and a time, full stop.
timestamptz (timestamp with time zone) does not store a timezone. This surprises people who assume the name implies Postgres remembers “this was entered in America/Los_Angeles.” It doesn’t. The general model: on write, the incoming value is interpreted against a timezone (either because it carried one, or via the session timezone), normalized to a UTC instant, and stored as that instant. On read, Postgres converts that same instant into whatever timezone the current session is set to, and hands you back a localized string. There is no timezone label stored alongside the value — only the normalized instant, plus session-time display logic layered on top at read time.
That distinction — normalize-to-UTC-on-write, render-per-session-on-read — is the entire mental model you need, and it’s also why “just store UTC” isn’t a complete answer. Storing UTC is what timestamptz already does for you. The bug surface is in the reading, not the storing.
Side-by-side comparison
timestamp (without time zone) | timestamptz (with time zone) | |
|---|---|---|
| What’s stored | A literal date/time value, no offset attached | A UTC instant, derived from input + session timezone at write time |
| Behavior on write | Stores exactly what you typed, as-is | Converts input to UTC using the session’s timezone setting |
| Behavior on read | Returns the literal value unchanged | Converts stored UTC to the current session’s timezone and displays it that way |
Sensitive to session timezone setting? | No — value never changes | Yes — same row can display differently per session |
| Right for | Values that are genuinely timezone-less by meaning (rare) | Any real-world “this happened at an instant” event |
| Default recommendation | Avoid unless you have a specific reason | Default choice for event timestamps |
For event data — created_at, updated_at, payment_processed_at, login_at — timestamptz should be your default and timestamp the deliberate exception.
Worked example: same value, two types, one session change
Say you insert a row with the literal clock value 2026-12-31 23:59:00, entered by a user in Los Angeles (Pacific time), into both a timestamp and a timestamptz column, with the session timezone set to America/Los_Angeles at insert time.
Now a reporting session connects with SET TIME ZONE 'America/New_York' and runs a SELECT against both columns.
- The
timestampcolumn still returns2026-12-31 23:59:00— unchanged, because it never had a timezone to reinterpret. If your application code then assumes that literal value is Eastern time and books the sale to December, you get a silently wrong answer: the value never moved, but your interpretation of it did. - The
timestamptzcolumn, having normalized the original Pacific instant to UTC on write, now renders that same UTC instant in Eastern time — which correctly rolls the sale into January 1st, because 23:59 Pacific genuinely is early morning the next day on the East Coast.
Consider a sale entered at 23:59 Pacific time on New Year’s Eve. Read back under the wrong timezone assumption, that timestamp can land on the wrong day — or even the wrong month — entirely. A naive timestamp column has no timezone information attached, so there’s nothing to stop a mismatched offset from silently shifting the value when it’s read back. The type you chose at table-creation time determines whether that conversion is reliable or just a guess that happens to work until it doesn’t.
Session and server timezone configuration
timestamptz only produces correct output if you know what the session timezone actually is — it doesn’t eliminate the problem, it just relocates it to a configuration question.
A few concrete rules:
- Don’t assume server timezone matches your laptop. Cloud-hosted Postgres on AWS, GCP, Azure, and Digital Ocean is typically set to UTC by default, regardless of which physical region the data center sits in. If you develop locally with your machine’s local timezone and deploy to a UTC-default cloud instance, your dates will appear to shift the moment you ship.
SET TIME ZONEis per-session, not global, unless you changepostgresql.conf. That’s usually what you want — each client declares the zone it wants output rendered in — but it means every connection path in your stack needs to agree on what it’s asking for.- Connection poolers can break this assumption. A documented Django Forum report describes exactly this failure mode: running Django against PostgreSQL 16.3 behind pgpool in high-availability mode, where Django defaults to UTC with timezone-aware sessions, but PostgreSQL itself was configured for Europe/Berlin. Because pgpool can route a session from a reader to a writer node mid-query, Django’s per-session
SET TIME ZONEcommand isn’t always reliably applied, so the application reads back a different timestamp than it saved. - Workarounds that actually hold up: force the Postgres server itself to run in UTC rather than relying on per-session negotiation, set a
TZenvironment variable so the driver has a consistent default, or — per a suggestion from a forum responder on that same thread — specify the timezone explicitly per query instead of trusting session state to survive pooler hops.
Postgres contributor Laurenz Albe’s position on this, stated on Stack Overflow, is worth internalizing as a design rule rather than a tip: it’s fine to manage timezones inside PostgreSQL — treating “timezone” as meaning the client’s timezone, with each session setting the parameter correctly — or fine to manage timezones entirely in the application layer, but mixing the two strategies is where teams get into trouble. Pick one layer to own timezone conversion. My own preference is the application layer, specifically the API boundary, because it’s easier to test and reason about than session-scoped database state.
Store UTC, convert at display — but that’s only half the job
“Just store everything in UTC” is the advice everyone’s heard, and it’s necessary but not sufficient. timestamptz already gives you UTC storage for free. The part that advice skips is: something still has to convert that UTC instant into a human’s local time correctly, and decide where that conversion happens.
When a query or view needs wall-clock time or day boundaries in a particular timezone, be explicit about that zone in the query rather than relying on whatever timezone the session happens to be in.
But for anything user-facing, I’d push the conversion further downstream — to the API boundary or the client — rather than baking a specific business timezone into database views, because “today” and “this month” mean different things to users in different timezones, and the database shouldn’t have to know who’s asking.
The FastAPI boundary: Pydantic, JSON, and Zod
This is where a lot of otherwise-correct Postgres setups still produce wrong-looking timestamps on screen, because the chain has one more link: serialization.
Check what your Python driver or ORM hands back for timestamptz columns; you want timezone-aware datetime objects, not naive ones. Pydantic models in FastAPI should type these fields as datetime (not date, not a naive string) so that timezone awareness survives the ORM-to-API hop rather than being silently stripped. The wire format should be an ISO 8601 string carrying an explicit offset or a Z suffix — that’s what lets a client unambiguously reconstruct the instant. If you ever see a bare 2026-12-31T23:59:00 with no offset cross this boundary, treat it as a bug: you’ve reintroduced the naive-timestamp ambiguity one layer up the stack, and the client has no way to know what it’s looking at.
On the TypeScript side, keeping Zod schemas and Pydantic models in sync for these fields matters more than it sounds like it should — a field typed as string in one place and Date in another is exactly how an offset silently gets dropped during a refactor. Treat the wire format as the ISO 8601 string, and let each side’s framework do the parsing into its native datetime type at the boundary rather than fighting over which “owns” the real type.
Next.js: where instant becomes display
Here’s the part that bites teams who got everything upstream right. A documented Next.js bug illustrates it precisely: a birth date fetched server-side in getServerSideProps as 2001-01-05 00:00:00.000 rendered, once passed to the page component, as a date one day earlier and several hours off — Thu Jan 04 2001 22:00:00 GMT-0200 in Brasília time. Both values represent the same instant. The server-side value was being serialized/interpreted as UTC, while the client-side Date object rendered it in the browser’s local timezone.
This is the hydration-mismatch trap: a value computed or fetched server-side, then re-rendered client-side, can disagree with itself purely because one render path assumes UTC and the other assumes browser-local time — even with zero network or database error involved. The fix isn’t exotic: decide explicitly, per field, whether you’re displaying an instant (convert to the viewer’s local timezone deliberately, using Intl.DateTimeFormat or similar, not an implicit Date render) or a date-only value (don’t put it through timezone conversion at all).
Date-only values deserve their own type, not a clever workaround
Birthdates, anniversaries, “valid from” dates on a coupon — these don’t have an instant in time, they have a calendar date, and forcing them through timestamp or timestamptz is a category error. Postgres’s date type is the correct column choice here.
The JavaScript side is where this gets genuinely awkward: a JS Date object always carries timezone notion internally, even when the value it’s meant to represent — a birth date — conceptually has none. A maintainer response on the Next.js/superjson issue above flags exactly this: storing birth dates as JS Date objects is problematic for that reason, and until the TC39 Temporal proposal (which was at Stage 3 at the time of that discussion, aimed at giving JS a proper timezone-less date type) is broadly available, the pragmatic workaround is to keep date-only values as plain YYYY-MM-DD strings and avoid constructing a Date object from them at all. On the Postgres/API side, that means serializing date columns as plain ISO date strings, and keeping your Pydantic model typed as date, not datetime, so nothing upstream accidentally attaches a time component or timezone to a value that never had one.
Views, computed fields, and the queries that touch them
Once your columns are typed correctly, most remaining pitfalls live in queries and views that compute or filter on time. now() returns the current time as a timestamptz, already in UTC. A view intended to expose “orders from today” needs an explicit AT TIME ZONE against a known business timezone (not the querying session’s ambient setting) if “today” is meant to mean something consistent regardless of who’s running the report. Treat any computed or view-level date-range filter as a place to be explicit about the timezone rather than relying on whatever timezone happens to be set on the connection that executes it.
If you’re exposing any of this over GraphQL — Hasura in particular — the platform maps Postgres date, time, and timestamptz columns to matching custom scalars, expecting ISO 8601 input and output, and it supports passing a Postgres expression like now() directly as a mutation value for a timestamptz column rather than requiring the client to compute and send a timestamp string. That’s a convenient escape hatch when you want the database, not the client, to be the source of truth for “when did this actually happen”.
The pattern, end to end
- Default every event-time column to
timestamptz; usetimestamponly when you deliberately want a timezone-less literal, and usedatefor anything that’s a calendar date with no time component. - Pin your server-side Postgres timezone rather than trusting session negotiation to survive poolers and node failovers, following the UTC-server, explicit-TZ-env-var pattern that resolved the Django/pgpool case.
- Pick one layer to own timezone conversion and don’t mix the two strategies — the position Laurenz Albe describes on Stack Overflow: manage it in the database or in the application, not both.
- Keep Pydantic and Zod/TypeScript types aligned on ISO 8601 strings with explicit offsets as the wire format, letting each framework parse into its native aware-datetime type at the boundary.
- In Next.js, convert instants to local display time deliberately on the client, and never let a date-only value pass through a timezone-aware
Dateobject.
Get all five right and the 23:59-PST-sale problem simply can’t happen — not because you memorized a rule, but because every hop in the chain agrees on what instant it’s talking about.
FAQ
Is timestamptz slower or larger than timestamp?
The choice between them should be made on correctness grounds, not performance — pick based on which conversion behavior at read/write time matches what your application needs, not on any assumed storage or speed difference.
If I store everything in UTC, do I still need timestamptz?
Yes. timestamptz is what normalizes your input to UTC consistently regardless of session timezone; relying on a plain timestamp column and a team convention of “we always insert UTC” is fragile the moment any connection, pooler, or developer’s session disagrees about what “UTC” means locally.
Should I convert to the user’s timezone in the database or in the frontend? Either can work, but don’t split it across both — Albe’s guidance on owning timezone handling in one layer applies directly here, and most teams find the frontend (or API boundary) easier to test and reason about than session-scoped Postgres state.
What should I use for a birthdate column?
Postgres date, serialized as a plain YYYY-MM-DD string over the wire, with Pydantic typed as date — avoid constructing a JS Date object from it client-side until Temporal-style plain-date handling is standard.