PostgreSQL's timestamp and timestamptz Implementation in C

timestamp and timestamptz are the same 8-byte integer — microseconds since 2000-01-01. The only difference is that one of them pretends a time zone when it reads and writes, which means comparing the two depends on your session TimeZone.

Here is the punchline up front: in PostgreSQL, timestamp and timestamptz are the same value. Both are an 8-byte signed integer counting microseconds since 2000-01-01. The type names suggest two different things — one naive, one zone-aware — but the storage layer cannot tell them apart. The entire distinction lives in the input and output functions, which consult the session TimeZone setting.

That single decision explains why timestamptz sorts with a plain int64 compare, why the binary wire format is identical for both types, and why comparing a timestamp to a timestamptz can flip from true to false just because someone ran SET TIME ZONE.

This is a walk through the C implementation, based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The catalog entries

From src/include/catalog/pg_type.dat, timestamp (lines 299–304) and timestamptz (lines 305–311):

text
{ oid => '1114', array_type_oid => '1115', descr => 'date and time',
  typname => 'timestamp', typlen => '8', typbyval => 't', typcategory => 'D',
  typinput => 'timestamp_in', typoutput => 'timestamp_out',
  typreceive => 'timestamp_recv', typsend => 'timestamp_send',
  typmodin => 'timestamptypmodin', typmodout => 'timestamptypmodout',
  typalign => 'd' },
{ oid => '1184', array_type_oid => '1185',
  descr => 'date and time with time zone',
  typname => 'timestamptz', typlen => '8', typbyval => 't', typcategory => 'D',
  typispreferred => 't', typinput => 'timestamptz_in',
  typoutput => 'timestamptz_out', typreceive => 'timestamptz_recv',
  typsend => 'timestamptz_send', typmodin => 'timestamptztypmodin',
  typmodout => 'timestamptztypmodout', typalign => 'd' },

The two rows are nearly identical:

Fieldtimestamptimestamptz
oid11141184
array oid11151185
typlen88
typbyvaltt
typaligndd
typispreferred(absent)t

Same width, same pass-by-value, same 8-byte alignment, no typstorage (so it defaults to p, PLAIN — nothing to compress or TOAST). The one asymmetry is typispreferred: an untyped string literal such as 'now' resolves to timestamptz, not timestamp.

The C representation

Both types are one-line typedefs in src/include/datatype/timestamp.h:

c
typedef int64 Timestamp;
typedef int64 TimestampTz;
typedef int64 TimeOffset;
typedef int32 fsec_t;			/* fractional seconds (in microseconds) */

src/include/utils/timestamp.h turns that into Datum conversions, with a comment that is the whole story:

c
/*
 * For Timestamp, we make use of the same support routines as for int64.
 * Therefore Timestamp is pass-by-reference if and only if int64 is!
 */
static inline Timestamp
DatumGetTimestamp(Datum X)
{
	return (Timestamp) DatumGetInt64(X);
}

static inline Datum
TimestampGetDatum(Timestamp X)
{
	return Int64GetDatum(X);
}

On a 64-bit machine int64 is passed by value, so a timestamp is a Datum holding the integer itself. In a heap tuple it is stored inline like any other by-value fixed-length attribute — no pointer, no header, no alignment padding beyond typalign = 'd'.

text
+---------------------------------------+
| Timestamp / TimestampTz  (int64)      |
| microseconds since 2000-01-01 00:00   |
+---------------------------------------+
 0                                     7

Microseconds since 2000, via Julian day

The unit is microseconds; the origin is the Julian day constant POSTGRES_EPOCH_JDATE (src/include/datatype/timestamp.h):

c
#define UNIX_EPOCH_JDATE		2440588 /* == date2j(1970, 1, 1) */
#define POSTGRES_EPOCH_JDATE	2451545 /* == date2j(2000, 1, 1) */
#define USECS_PER_DAY	INT64CONST(86400000000)
#define USECS_PER_HOUR	INT64CONST(3600000000)
#define USECS_PER_SEC	INT64CONST(1000000)

Conversion from a broken-down pg_tm is tm2timestamp() (src/backend/utils/adt/timestamp.c), which is pure integer arithmetic with overflow guards:

c
	date = date2j(tm->tm_year, tm->tm_mon, tm->tm_mday) - POSTGRES_EPOCH_JDATE;
	time = time2t(tm->tm_hour, tm->tm_min, tm->tm_sec, fsec);

	if (unlikely(pg_mul_s64_overflow(date, USECS_PER_DAY, result) ||
				 pg_add_s64_overflow(*result, time, result)))
	{
		*result = 0;			/* keep compiler quiet */
		return -1;
	}
	if (tzp != NULL)
		*result = dt2local(*result, -(*tzp));

Note the tzp parameter. timestamp_in() passes NULL; timestamptz_in() passes the parsed zone offset. dt2local() is just dt -= timezone * USECS_PER_SEC. That is the entire runtime difference between the two types at input time: one applies an offset, the other does not.

The range is deliberately narrower than int64 allows:

c
#define MIN_TIMESTAMP	INT64CONST(-211813488000000000)
#define END_TIMESTAMP	INT64CONST(9223371331200000000)

#define IS_VALID_TIMESTAMP(t)  (MIN_TIMESTAMP <= (t) && (t) < END_TIMESTAMP)

That is 4714-11-24 BC through 294276-12-31 AD, leaving slop below the point where int64 microseconds would overflow.

The extremes of the range are reserved for infinity, exactly as with date:

c
#define TIMESTAMP_MINUS_INFINITY	PG_INT64_MIN
#define TIMESTAMP_INFINITY	PG_INT64_MAX

#define TIMESTAMP_NOBEGIN(j)	\
	do {(j) = DT_NOBEGIN;} while (0)
#define TIMESTAMP_NOT_FINITE(j) (TIMESTAMP_IS_NOBEGIN(j) || TIMESTAMP_IS_NOEND(j))

So '-infinity'::timestamptz is literally INT64_MIN, and every arithmetic path checks TIMESTAMP_NOT_FINITE before touching the bits.

Output, and where the timezone comes back

timestamp_out() and timestamptz_out() both call timestamp2tm() and then EncodeDateTime(). The difference is again one argument — timestamp_out passes NULL for the zone pointer, timestamptz_out passes &tz:

c
	else if (timestamp2tm(dt, &tz, tm, &fsec, &tzn, NULL) == 0)
		EncodeDateTime(tm, fsec, true, tz, tzn, DateStyle, buf);

timestamp2tm() splits the value into a day count and a within-day microsecond count, then rotates J2000 back to the Julian epoch and calls j2date()/dt2time():

c
	time = dt;
	TMODULO(time, date, USECS_PER_DAY);

	if (time < INT64CONST(0))
	{
		time += USECS_PER_DAY;
		date -= 1;
	}

	/* add offset to go from J2000 back to standard Julian date */
	date += POSTGRES_EPOCH_JDATE;
	...
	j2date((int) date, &tm->tm_year, &tm->tm_mon, &tm->tm_mday);
	dt2time(time, &tm->tm_hour, &tm->tm_min, &tm->tm_sec, fsec);

Only when tzp != NULL does it call pg_localtime() to rotate the instant into the session zone, filling tm_gmtoff and setting *tzp = -tm->tm_gmtoff. For dates outside pg_time_t’s range it falls back to treating the value as GMT. A naive timestamp never enters that branch.

Casting, and cross-type comparison

The cast functions make the reinterpretation explicit. timestamp2timestamptz_safe() reads the naive value, looks up the offset for that local time in the session zone, and shifts it:

c
		tz = DetermineTimeZoneOffset(tm, session_timezone);
		result = dt2local(timestamp, -tz);

timestamptz2timestamp_safe() goes the other way through timestamp2tm/tm2timestamp. Neither changes the instant; they change which wall-clock instant the integer is said to denote.

Cross-type comparison inherits the same rule (timestamp_cmp_timestamptz_internal() in timestamp.c): it calls timestamp2timestamptz_safe() and then compares as int64. So timestamp '2024-01-01 12:00' equals timestamptz '2024-01-01 12:00+00' only if your session zone is UTC.

Typmod rounds, it does not truncate

timestamp(3) keeps three fractional-second digits. The typmod is just the digit count (0–6, capped by MAX_TIMESTAMP_PRECISION), and AdjustTimestampForTypmod() rounds half-up using a scale and offset table:

c
	static const int64 TimestampScales[MAX_TIMESTAMP_PRECISION + 1] = {
		INT64CONST(1000000), INT64CONST(100000), INT64CONST(10000),
		INT64CONST(1000), INT64CONST(100), INT64CONST(10), INT64CONST(1)
	};
	static const int64 TimestampOffsets[MAX_TIMESTAMP_PRECISION + 1] = {
		INT64CONST(500000), INT64CONST(50000), INT64CONST(5000),
		INT64CONST(500), INT64CONST(50), INT64CONST(5), INT64CONST(0)
	};

The offset is half a unit of the target precision, so .1235 rounds up and .1234 does not. The negative branch negates, rounds, and re-negates, so rounding is symmetric around the epoch.

Wire format, comparison, hashing

The binary representation is the bare integer, identical for both types:

c
Datum
timestamp_send(PG_FUNCTION_ARGS)
{
	Timestamp	timestamp = PG_GETARG_TIMESTAMP(0);
	StringInfoData buf;

	pq_begintypsend(&buf);
	pq_sendint64(&buf, timestamp);
	PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}

timestamp_recv() reads it with pq_getmsgint64(), then range-checks by calling timestamp2tm() and IS_VALID_TIMESTAMP(). Comparison is a three-way int64 compare (timestamp_cmp_internal()), sort support is wired straight to ssup_datum_int64_cmp, and hashing is delegated to hashint8 — the same routine int8 uses:

c
Datum
timestamptz_hash(PG_FUNCTION_ARGS)
{
	return hashint8(fcinfo);
}

There is no normalization, no calendar math, and no timezone lookup anywhere in the ordering hot path. That work is paid once, at input.

Look at it yourself

The catalog rows confirm the shared layout, and the epoch arithmetic is directly observable:

sql
SELECT typname, typlen, typbyval, typalign, typarray, typispreferred
FROM pg_type
WHERE typname IN ('timestamp', 'timestamptz');

-- the internal origin is 2000-01-01, not 1970-01-01
SELECT '2000-01-01 00:00:00'::timestamp - '1970-01-01 00:00:00'::timestamp;  -- 946684800
SELECT 'epoch'::timestamptz;                                                 -- 1970-01-01 00:00:00+00

SELECT pg_column_size('2024-01-01 00:00:00'::timestamp),                      -- 8
       pg_column_size('2024-01-01 00:00:00+00'::timestamptz);                 -- 8

And the timezone dependency of cross-type equality is easy to reproduce:

sql
SET TIME ZONE 'UTC';
SELECT '2024-01-01 12:00:00'::timestamp = '2024-01-01 12:00:00+00'::timestamptz;  -- true
SET TIME ZONE 'America/New_York';
SELECT '2024-01-01 12:00:00'::timestamp = '2024-01-01 12:00:00+00'::timestamptz;  -- false

Same two literals, same two integers on disk, different answer — because the comparison converted the naive value using the current session zone.

What it all means

  • timestamp and timestamptz are both int64 microseconds since 2000-01-01; typlen = 8, typbyval = t, typalign = d, PLAIN storage.
  • The only difference is I/O: timestamptz_in/out consult the session TimeZone via DetermineTimeZoneOffset/pg_localtime, timestamp does not.
  • Casting between them is a reinterpretation of the same instant, not a conversion; cross-type comparison does the same thing, so it is session-zone-dependent.
  • Sorting and hashing are plain int64 operations (hashint8, ssup_datum_int64_cmp) — calendar and zone logic live only at the edges.
  • Infinity is INT64_MIN/INT64_MAX, and every arithmetic path short-circuits on TIMESTAMP_NOT_FINITE.
  • Typmod is a 0–6 digit precision that rounds half-up, symmetrically for negative values.

Further reading

  • src/include/catalog/pg_type.dat — the timestamp/timestamptz entries.
  • src/include/datatype/timestamp.h — typedefs, epoch and range constants, infinity macros.
  • src/include/utils/timestamp.h — DatumGetTimestamp, DatumGetTimestampTz, arithmetic helpers.
  • src/backend/utils/adt/timestamp.c — timestamp_in/out/recv/send, timestamptz_in/out/recv/send, timestamp2tm, tm2timestamp, AdjustTimestampForTypmod, timestamp_cmp_timestamptz_internal, timestamp2timestamptz_safe, SetEpochTimestamp.
  • src/backend/utils/adt/datetime.c — date2j, j2date, dt2time, EncodeDateTime.

PostgresInternalsTimestampTimestamptz