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):
{ 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:
| Field | timestamp | timestamptz |
|---|---|---|
| oid | 1114 | 1184 |
| array oid | 1115 | 1185 |
typlen | 8 | 8 |
typbyval | t | t |
typalign | d | d |
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:
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:
/*
* 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'.
+---------------------------------------+
| Timestamp / TimestampTz (int64) |
| microseconds since 2000-01-01 00:00 |
+---------------------------------------+
0 7Microseconds since 2000, via Julian day
The unit is microseconds; the origin is the Julian day constant
POSTGRES_EPOCH_JDATE (src/include/datatype/timestamp.h):
#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:
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:
#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:
#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:
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():
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:
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:
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:
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:
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:
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); -- 8And the timezone dependency of cross-type equality is easy to reproduce:
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; -- falseSame 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
timestampandtimestamptzare bothint64microseconds since 2000-01-01;typlen = 8,typbyval = t,typalign = d, PLAIN storage.- The only difference is I/O:
timestamptz_in/outconsult the sessionTimeZoneviaDetermineTimeZoneOffset/pg_localtime,timestampdoes 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
int64operations (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 onTIMESTAMP_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— thetimestamp/timestamptzentries.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.