Back to Blog

Time zones in Postgres — timestamptz vs timestamp

April 9, 2026
4 min read
Written by ConvertTime Team
postgrespostgresqltime-zonesdevelopersdatabases
PostgreSQL has two timestamp data types: `timestamp` (without time zone) and `timestamptz` (with time zone). Despite their names, the difference is more about *how* they handle time zones than what they store. Understanding this distinction prevents some of the most common date-related bugs. For converting Unix timestamps to/from human-readable dates, the [epoch converter](/calculators/epoch) handles them. ## The two types ### `timestamp` (without time zone) Stores: a "wall clock" time. Year, month, day, hour, minute, second. Doesn't store: any time zone information. Behavior: when you insert `'2026-05-21 14:30:00'`, that's exactly what you get back. No conversion happens. Use cases: storing dates that don't have an associated time zone (e.g., "the meeting is at 3 PM" without specifying which zone). Rare; usually problematic. ### `timestamptz` (with time zone) Stores: an absolute moment in time, internally as UTC. The time zone information is stored separately as metadata. Behavior: - On INSERT: converts your input to UTC for storage - On SELECT: converts back to your session's time zone for display Use cases: 99% of all timestamp storage. Logs, events, schedules, modifications — anything with a real moment in time. ## The fundamental difference ```sql -- Set session time zone to America/New_York SET TIMEZONE = 'America/New_York'; -- Insert into both types INSERT INTO test (ts, tstz) VALUES ('2026-05-21 14:30:00', '2026-05-21 14:30:00'); -- Switch session to UTC SET TIMEZONE = 'UTC'; -- Query SELECT ts, tstz FROM test; -- ts: 2026-05-21 14:30:00 -- tstz: 2026-05-21 18:30:00 (because EDT is -4) ``` The `timestamp` value didn't change — it was stored as wall-clock and retrieved as wall-clock. The `timestamptz` value changed — Postgres converted from your insert session's zone to UTC for storage, then to your select session's zone for retrieval. For most use cases, you want `timestamptz`. The wall-clock-only behavior of `timestamp` causes bugs. ## When to use which **Use `timestamptz` for:** - All "this happened at this moment" timestamps - Created/updated/deleted timestamps - Event logs - Scheduling with absolute moments - User session tracking - Almost everything **Use `timestamp` for:** - Truly time-zone-independent dates (like a recurring meeting time you'll convert at runtime) - Imported data where the source has no time zone In practice, almost always use `timestamptz`. The "without time zone" type is a footgun in modern applications. ## Setting time zones PostgreSQL has several time zone concepts: **Server time zone**: the daemon's default. Set in `postgresql.conf`. Often UTC for production. **Session time zone**: per-connection. Set with `SET TIMEZONE`. **Per-statement time zone**: with `AT TIME ZONE`. ```sql -- Set session SET TIMEZONE = 'America/New_York'; -- Per-statement SELECT '2026-05-21 14:30:00 UTC' AT TIME ZONE 'Asia/Tokyo'; -- Returns: 2026-05-21 23:30:00 ``` Best practice: server time zone in UTC, applications convert at the display layer. ## Inserting timestamps Several ways to insert: ```sql -- ISO 8601 string (recommended) INSERT INTO events (occurred_at) VALUES ('2026-05-21T14:30:00Z'); -- Postgres-style with explicit zone INSERT INTO events (occurred_at) VALUES ('2026-05-21 14:30:00+00:00'); -- Without zone (uses session's time zone) INSERT INTO events (occurred_at) VALUES ('2026-05-21 14:30:00'); -- Above is dangerous — session zone matters -- Using NOW() INSERT INTO events (occurred_at) VALUES (NOW()); -- Using AT TIME ZONE for clarity INSERT INTO events (occurred_at) VALUES ( '2026-05-21 09:30:00' AT TIME ZONE 'America/New_York' ); -- Insert as 9:30 AM Eastern; stores as 13:30 UTC ``` Always specify the time zone in your insert if possible. Implicit session zones are bug-prone. ## Querying timestamps ```sql -- Basic SELECT (returns in session's time zone) SELECT occurred_at FROM events; -- Convert to specific zone SELECT occurred_at AT TIME ZONE 'Asia/Tokyo' FROM events; -- Format for display SELECT to_char(occurred_at, 'YYYY-MM-DD HH24:MI:SS TZ') FROM events; -- Compare with current time SELECT * FROM events WHERE occurred_at > NOW() - INTERVAL '1 day'; -- Filter by date range SELECT * FROM events WHERE occurred_at BETWEEN '2026-05-01' AND '2026-05-31'; ``` ## Comparing timestamps ```sql -- Equality SELECT * FROM events WHERE occurred_at = '2026-05-21T14:30:00Z'; -- Range SELECT * FROM events WHERE occurred_at >= '2026-05-21' AND occurred_at < '2026-05-22'; -- Time delta SELECT * FROM events WHERE occurred_at > NOW() - INTERVAL '7 days'; ``` When comparing `timestamptz` values, Postgres converts both sides to UTC and compares. Time zones don't cause comparison bugs. When comparing `timestamp` (without zone), Postgres compares wall-clock values. This can give wrong results across DST boundaries. ## DST behavior `timestamptz` handles DST automatically: ```sql -- Insert before DST SET TIMEZONE = 'America/New_York'; INSERT INTO events (occurred_at) VALUES ('2026-03-08 01:30:00 EST'); -- After DST (clocks have shifted forward) INSERT INTO events (occurred_at) VALUES ('2026-03-08 01:30:00 EDT'); -- Both are valid, distinct UTC times ``` `timestamp` doesn't: ```sql -- '2026-11-01 01:30:00' (with timestamp, no zone) is ambiguous -- (this hour happens twice in fall-back) -- Postgres just stores the wall-clock; you can't distinguish ``` Another reason to prefer `timestamptz`. ## Indexes and performance Both types index well. No performance difference between them for typical queries. The cost of `timestamptz` is minimal — Postgres internally stores both as 8-byte integers (microseconds since epoch). The "with time zone" is metadata about how to interpret the value, not a separate field. ## Common patterns ### Recording event timestamps ```sql CREATE TABLE events ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, event_type VARCHAR(50) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ### User-specific local times ```sql -- Store user's preferred time zone CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(255), timezone VARCHAR(64) DEFAULT 'UTC' ); -- Display events in user's local time SELECT e.id, e.occurred_at AT TIME ZONE u.timezone AS local_time FROM events e JOIN users u ON e.user_id = u.id; ``` ### Time-windowed aggregations ```sql -- Count events per hour for the last 24 hours SELECT date_trunc('hour', occurred_at) AS hour, COUNT(*) AS count FROM events WHERE occurred_at > NOW() - INTERVAL '24 hours' GROUP BY hour ORDER BY hour; ``` ### Recurring schedules ```sql -- Use a recurring rule rather than absolute times CREATE TABLE recurring_jobs ( id SERIAL PRIMARY KEY, cron_expression VARCHAR(50) NOT NULL, timezone VARCHAR(64) DEFAULT 'UTC', last_run_at TIMESTAMPTZ ); ``` ## Common mistakes **Storing as `timestamp` instead of `timestamptz`**: leads to ambiguous timestamps across time zones. **Setting wrong session time zone**: implicit conversions go wrong. **Manually converting before insert**: instead of letting Postgres handle it. **Using `localtime` operator**: deprecated, ambiguous. Use `AT TIME ZONE`. **Forgetting that `NOW()` returns `timestamptz`**: this is usually correct, but mixed with `timestamp` columns it confuses. ## Migration: from `timestamp` to `timestamptz` If you have a column declared as `timestamp` but realize you should have used `timestamptz`: ```sql -- Migrate column ALTER TABLE events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at AT TIME ZONE 'UTC'; ``` This converts existing values, assuming they were UTC. If they were in a different zone, replace `'UTC'` with the correct zone. ## FAQ ### Should I always use `timestamptz`? Almost always. The only exception is if you have a strong reason to store wall-clock times explicitly, which is rare. ### What does Postgres store internally? Both types store an 8-byte integer (microseconds since 2000-01-01 00:00:00 UTC). The "with time zone" type adds metadata about how to format on output. ### Does `AT TIME ZONE` change the underlying timestamp? No — it only converts for display. The stored value is unchanged. ### What's the difference between `NOW()` and `CURRENT_TIMESTAMP`? Both return `timestamptz`. They're equivalent. ### Can I compare a `timestamp` with a `timestamptz`? Yes, but Postgres applies the session's time zone to interpret the `timestamp` first. This is bug-prone — prefer using `timestamptz` consistently. ### What about `time` and `timetz`? `time` is just hour:minute:second (no date). `timetz` adds time zone. Both are rarely useful in practice — full timestamps are usually what you want. ## Bottom line Use `timestamptz` for all timestamp columns. PostgreSQL handles time zone conversions automatically; `timestamp` (without zone) is a footgun. Set your server time zone to UTC and let your applications convert for display. Always specify time zones in inserts and use `AT TIME ZONE` for explicit conversions. For converting Unix epoch values to/from PostgreSQL timestamps, the [epoch converter](/calculators/epoch) handles them.

Share this article

Related Articles