Limited Time Offer: 40% off

PostgreSQL timestamp vs timestamptz

Use timestamptz. It is the same 8 bytes as timestamp and it does not throw away the offset.

Quick answer

Use timestamptz unless you have a specific reason not to.

SQL
CREATE TABLE events (
  id         bigserial PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now()
);

Both types take 8 bytes. timestamptz is not more expensive, and it is the one that does not silently discard information.

The two types

TypeAliasesBytesStores
timestamptimestamp without time zone8A date and time, with no reference to any zone
timestamptztimestamp with time zone8A specific moment in time

The names are misleading, and this is the root of most of the confusion: timestamptz does not store a time zone. Neither type stores a time zone.

What actually happens:

  • timestamp takes the date and time you give it and stores those digits. If your input had an offset, it is thrown away.
  • timestamptz uses the offset to convert your input to UTC, stores that, and then renders it back in your session's time zone when you read it.

So timestamptz stores a moment. timestamp stores a reading on a clock, without saying whose clock.

Watch it happen

Insert the same literal, with an offset, into both:

SQL
SET timezone = 'UTC';

CREATE TABLE ts_demo (a timestamp, b timestamptz);
INSERT INTO ts_demo VALUES ('2026-07-01 12:00:00+05', '2026-07-01 12:00:00+05');

SELECT a AS "timestamp", b AS "timestamptz" FROM ts_demo;
      timestamp      |      timestamptz
---------------------+------------------------
 2026-07-01 12:00:00 | 2026-07-01 07:00:00+00

The timestamp column kept 12:00:00 and dropped the +05. The timestamptz column understood that 12:00 +05 is 07:00 UTC and stored that moment.

Now change the session time zone and read the same row again:

SQL
SET timezone = 'America/New_York';
SELECT a AS "timestamp", b AS "timestamptz" FROM ts_demo;
      timestamp      |      timestamptz
---------------------+------------------------
 2026-07-01 12:00:00 | 2026-07-01 03:00:00-04

The timestamp value did not move, because it never meant anything in particular. The timestamptz value is the same instant, displayed for a reader in New York.

Nothing was rewritten on disk. Only the rendering changed.

And the storage cost:

SQL
SELECT pg_column_size(a) AS ts_bytes, pg_column_size(b) AS tstz_bytes FROM ts_demo;
 ts_bytes | tstz_bytes
----------+------------
        8 |          8

Identical. Choosing timestamp to save space saves nothing.

Which one should you use?

Use timestamptz for anything that records when something happened. Created-at, updated-at, logged-in-at, ordered-at. These are moments. Your users are in different zones, your servers may be too, and daylight saving will move underneath you twice a year.

Use timestamp only for a wall-clock reading with no zone, which is rarer than people think. A genuine example: a recurring alarm that should fire at 09:00 local time wherever the user happens to be. That is not a moment, it is a clock reading, so timestamp is right and timestamptz would be wrong.

If you are unsure, you want timestamptz. The failure mode of timestamp is that it looks fine in development, where everything is one zone, and produces off-by-hours bugs in production that only appear for some users, or only in summer.

The session time zone

timestamptz renders in your session's zone, which is set by the timezone parameter:

SQL
SHOW timezone;
 TimeZone
----------
 UTC

Set it per session:

SQL
SET timezone = 'Europe/London';

Or per database, permanently:

SQL
ALTER DATABASE mydb SET timezone = 'UTC';

Running your database in UTC is a good default. It means what you see in psql matches what is stored, which removes a whole class of "is this already converted?" confusion when debugging.

Note that this is a display setting for timestamptz. Changing it does not alter stored data.

AT TIME ZONE

AT TIME ZONE converts between the two types, and its behaviour depends on which type you feed it. This trips people up because the same syntax does two opposite things.

timestamptz AT TIME ZONE zone gives you a timestamp. It asks "what did the clock read in that zone at that moment?"

SQL
SELECT '2026-07-01 12:00:00+00'::timestamptz AT TIME ZONE 'America/New_York';
      timezone
---------------------
 2026-07-01 08:00:00

timestamp AT TIME ZONE zone gives you a timestamptz. It asks "this clock reading was in that zone, so which moment was it?"

SQL
SELECT '2026-07-01 12:00:00'::timestamp AT TIME ZONE 'America/New_York';
        timezone
------------------------
 2026-07-01 16:00:00+00

The rule: it always converts to the other type. If you are reaching for a second AT TIME ZONE to fix the first one, you probably want just one, in the other direction.

now(), current_timestamp, and clock_timestamp()

All three return a timestamptz, but they do not mean the same thing.

SQL
BEGIN;
SELECT now() = current_timestamp AS "now = current_timestamp";
SELECT now() = clock_timestamp() AS "now = clock_timestamp";
COMMIT;
 now = current_timestamp
-------------------------
 t

 now = clock_timestamp
-----------------------
 f
  • now() and current_timestamp are the same function. They return the time the transaction started, and that value does not move for the life of the transaction.
  • clock_timestamp() returns the actual current time, and it changes every time you call it.

This matters more than it looks. Inside a long transaction, now() is frozen. Every row you insert gets the same timestamp, which is usually what you want for consistency, and occasionally very much not what you want if you were trying to measure how long something took. For that, use clock_timestamp().

statement_timestamp() sits between them, returning the time the current statement started.

Common operations

Truncate to a unit:

SQL
SELECT date_trunc('day', now());
SELECT date_trunc('month', created_at) AS month, count(*) FROM events GROUP BY 1;

date_trunc on a timestamptz truncates according to your session zone, so "day" means a day in your zone. To truncate to a day in a specific zone regardless of session:

SQL
SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') FROM events;

Extract a part:

SQL
SELECT EXTRACT(year FROM now()), EXTRACT(dow FROM now());

Arithmetic with intervals:

SQL
SELECT now() - interval '7 days';
SELECT now() + interval '1 month 2 hours';

Interval arithmetic on timestamptz is daylight-saving aware. now() + interval '1 day' is the same clock time tomorrow, which may be 23 or 25 real hours. now() + interval '24 hours' is always 24 hours. They are different, and the difference bites exactly twice a year.

Difference between two timestamps:

SQL
SELECT age(end_time, start_time);           -- a human-readable interval
SELECT end_time - start_time;               -- an exact interval
SELECT EXTRACT(epoch FROM (end_time - start_time)); -- seconds as a number

Indexing and the trap in it

A plain index works as expected:

SQL
CREATE INDEX ON events (created_at);
SELECT * FROM events WHERE created_at >= now() - interval '1 day';

But wrapping the indexed column in a function stops the index being used:

SQL
-- Cannot use an index on created_at
SELECT * FROM events WHERE date_trunc('day', created_at) = '2026-07-01';

Rewrite as a range, which can:

SQL
SELECT * FROM events
WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02';

Range conditions are also more honest about zone boundaries. If you genuinely need the function, index the expression instead:

SQL
CREATE INDEX ON events (date_trunc('day', created_at));

Note that an expression index on date_trunc over a timestamptz depends on the session time zone, so PostgreSQL will not let you build one unless you pin the zone explicitly.

Migrating timestamp to timestamptz

If you already have a timestamp column holding UTC values, tell PostgreSQL that when you convert, or it will assume the values were in the session zone and shift them:

SQL
ALTER TABLE events
  ALTER COLUMN created_at TYPE timestamptz
  USING created_at AT TIME ZONE 'UTC';

Without the USING clause you get a silent, wrong conversion on every row. Take a backup first, and check a few rows afterwards.

Common problems

My timestamps are off by a few hours. You stored a moment in a timestamp column, so the offset was discarded, and something later assumed a zone. This is the type doing exactly what it promises.

Times shift when I change the server time zone. If they shift on disk, the column is timestamp. timestamptz values do not move; only their rendering changes.

now() returns the same value repeatedly. You are in a transaction. That is correct behaviour. Use clock_timestamp() for the moving value.

My date filter misses rows near midnight. Almost always a zone boundary. date_trunc('day', ...) on a timestamptz uses the session zone, so "today" depends on who is asking.

Quick reference

TaskSyntax
Recommended column typetimestamptz
Current transaction timenow() or current_timestamp
Actual current timeclock_timestamp()
Show session zoneSHOW timezone;
Set session zoneSET timezone = 'UTC';
Set database zoneALTER DATABASE db SET timezone = 'UTC';
Moment to local clock readingtstz AT TIME ZONE 'Europe/London'
Local clock reading to momentts AT TIME ZONE 'Europe/London'
Truncatedate_trunc('day', ts)
Add or subtractts + interval '7 days'
Seconds betweenEXTRACT(epoch FROM (b - a))
Convert column safelyALTER ... TYPE timestamptz USING col AT TIME ZONE 'UTC'