PostgreSQL's time and timetz Implementation in C
time is a 64-bit count of microseconds since midnight, and timetz is that plus a 4-byte zone offset — yet the struct is 16 bytes while the type declares itself 12, and both are correct.
A PostgreSQL time is a 64-bit integer counting microseconds since
midnight. A timetz is the same integer plus a 4-byte timezone offset. So
far so simple. The interesting part is the mismatch: the in-memory struct
TimeTzADT is 16 bytes, but the type declares itself 12 bytes wide,
and both numbers are correct — one is the padded struct, the other is what
actually gets copied into a tuple.
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, time (lines 291–295) and timetz
(lines 322–328):
{ oid => '1083', array_type_oid => '1183', descr => 'time of day',
typname => 'time', typlen => '8', typbyval => 't', typcategory => 'D',
typinput => 'time_in', typoutput => 'time_out', typreceive => 'time_recv',
typsend => 'time_send', typmodin => 'timetypmodin',
typmodout => 'timetypmodout', typalign => 'd' },
{ oid => '1266', array_type_oid => '1270',
descr => 'time of day with time zone',
typname => 'timetz', typlen => '12', typbyval => 'f', typcategory => 'D',
typinput => 'timetz_in', typoutput => 'timetz_out',
typreceive => 'timetz_recv', typsend => 'timetz_send',
typmodin => 'timetztypmodin', typmodout => 'timetztypmodout',
typalign => 'd' },The contrast is the whole story:
| Field | time | timetz |
|---|---|---|
typlen | 8 | 12 |
typbyval | t (by value) | f (by reference) |
typalign | d (8 bytes) | d (8 bytes) |
| array oid | 1183 | 1270 |
time is an 8-byte integer passed by value. timetz is a 12-byte value
passed by reference — and that combination is where the padding trap lives.
The C representation
From src/include/utils/date.h:
typedef int64 TimeADT;
typedef struct
{
TimeADT time; /* all time units other than months and years */
int32 zone; /* numeric time zone, in seconds */
} TimeTzADT;
/*
* sizeof(TimeTzADT) will be 16 on most platforms due to alignment padding.
* However, timetz's typlen is 12 according to pg_type. In most places
* we can get away with using sizeof(TimeTzADT), but where it's important
* to match the declared typlen, use TIMETZ_TYPLEN.
*/
#define TIMETZ_TYPLEN 12time occupies bytes 0–7 and zone bytes 8–11, so the useful data is
exactly 12 bytes. But the compiler pads the struct to 16 so that an
int64 member stays 8-byte aligned. The TIMETZ_TYPLEN macro exists
precisely to keep the two numbers straight.
Microseconds since midnight
time stores h/m/s as a flat microsecond count. The conversion is a
one-liner (tm2time()):
int
tm2time(struct pg_tm *tm, fsec_t fsec, TimeADT *result)
{
*result = ((((tm->tm_hour * MINS_PER_HOUR + tm->tm_min) * SECS_PER_MINUTE) + tm->tm_sec)
* USECS_PER_SEC) + fsec;
return 0;
}The constants come from src/include/datatype/timestamp.h:
USECS_PER_SEC = 1000000, USECS_PER_DAY = 86400000000. The inverse,
time2tm(), divides the count back out into hour, minute, second, and
fraction.
time_in() parses with DecodeTimeOnly() (the time-only sibling of
DecodeDateTime()), then calls tm2time() and AdjustTimeForTypmod().
time_out() calls time2tm() and EncodeTimeOnly(). The binary format is
just the 8 bytes: time_recv() uses pq_getmsgint64(), time_send() uses
pq_sendint64().
The range check is generous on purpose. time_overflows() allows hour
up to 24 and sec up to 60, then verifies the total does not exceed
USECS_PER_DAY:
/*
* Because we allow, eg, hour = 24 or sec = 60, we must check separately
* that the total time value doesn't exceed 24:00:00.
*/
if ((((((hour * MINS_PER_HOUR + min) * SECS_PER_MINUTE)
+ sec) * USECS_PER_SEC) + fsec) > USECS_PER_DAY)
return true;So '24:00:00'::time is a legal value.
Typmod and rounding
Both types accept a precision modifier, capped at MAX_TIME_PRECISION = 6
microsecond digits. AdjustTimeForTypmod() rounds (it does not truncate)
using a scale and offset table:
static const int64 TimeScales[MAX_TIME_PRECISION + 1] = {
INT64CONST(1000000), INT64CONST(100000), INT64CONST(10000),
INT64CONST(1000), INT64CONST(100), INT64CONST(10), INT64CONST(1)
};
static const int64 TimeOffsets[MAX_TIME_PRECISION + 1] = {
INT64CONST(500000), INT64CONST(50000), INT64CONST(5000),
INT64CONST(500), INT64CONST(50), INT64CONST(5), INT64CONST(0)
};
if (typmod >= 0 && typmod <= MAX_TIME_PRECISION)
{
if (*time >= INT64CONST(0))
*time = ((*time + TimeOffsets[typmod]) / TimeScales[typmod]) *
TimeScales[typmod];
else
*time = -((((-*time) + TimeOffsets[typmod]) / TimeScales[typmod]) *
TimeScales[typmod]);
}The offset is half a unit of the target precision, which is why
'12:00:00.123456'::time(3) rounds to .123 but .1235 rounds up. The
negative branch negates, rounds, and re-negates, so rounding is symmetric
around zero.
timetz: the 12-byte puzzle
timetz reuses the time machinery and adds zone, an int32 offset in
seconds. tm2timetz() is tm2time() plus one assignment:
int
tm2timetz(struct pg_tm *tm, fsec_t fsec, int tz, TimeTzADT *result)
{
result->time = ((((tm->tm_hour * MINS_PER_HOUR + tm->tm_min) * SECS_PER_MINUTE) + tm->tm_sec) *
USECS_PER_SEC) + fsec;
result->zone = tz;
return 0;
}The offset is bounded by TZDISP_LIMIT (src/include/datatype/timestamp.h):
#define MAX_TZDISP_HOUR 15 /* maximum allowed hour part */
#define TZDISP_LIMIT ((MAX_TZDISP_HOUR + 1) * SECS_PER_HOUR)timetz_recv() rejects anything at or beyond that bound, and
timetz_send() writes the 8-byte time followed by the 4-byte zone — 12
bytes total.
Here is the subtlety. timetz is typbyval = f, so a Datum holds a
pointer. In a heap tuple, though, a fixed-length by-reference attribute is
stored inline. In fill_val() (src/backend/access/common/heaptuple.c):
else
{
/* fixed-length pass-by-reference */
data = (char *) att_nominal_alignby(data, att->attalignby);
Assert(att->attlen > 0);
data_length = att->attlen;
memcpy(data, DatumGetPointer(datum), data_length);
}att->attlen is 12, so only 12 bytes are copied out of the 16-byte
TimeTzADT. The 4 padding bytes are simply dropped. When the value is read
back, fetch_att() returns PointerGetDatum(T) — a pointer straight into
the tuple’s data area — and the caller casts it to TimeTzADT *. That is
safe only because time and zone live in the first 12 bytes.
The declared typalign is d (8 bytes), so the field starts at an
8-byte-aligned offset and the next attribute is aligned again, giving an
effective 16-byte stride. The on-disk representation is 12 bytes; the
in-memory struct is 16. Both are correct, for different jobs.
Comparing timetz
timetz cannot be compared as a plain integer, because two values with the
same local time but different zones are different instants. The comparator
normalizes to a GMT-equivalent time-of-day first
(timetz_cmp_internal()):
/* Primary sort is by true (GMT-equivalent) time */
t1 = time1->time + (time1->zone * USECS_PER_SEC);
t2 = time2->time + (time2->zone * USECS_PER_SEC);
if (t1 > t2)
return 1;
if (t1 < t2)
return -1;
/*
* If same GMT time, sort by timezone; we only want to say that two
* timetz's are equal if both the time and zone parts are equal.
*/
if (time1->zone > time2->zone)
return 1;
...The sign convention is worth pinning down: zone is stored in seconds
west of Greenwich (positive west, the SQL convention), and
EncodeTimezone() negates it for display (tz <= 0 ? '+' : '-'). Adding
zone * USECS_PER_SEC to the local time therefore yields the UTC
time-of-day, which is what “true” means here. Two timetz values are equal
only when both the normalized time and the raw zone match.
Arithmetic
time has no infinity sentinels, but it does wrap. time_pl_interval()
adds an interval’s time field and folds the result back into a day:
result = time + span->time;
result -= result / USECS_PER_DAY * USECS_PER_DAY;
if (result < INT64CONST(0))
result += USECS_PER_DAY;interval_time() does the same modulo fold, and its comment explains the
choice for negative values: '-2 hours' becomes '22:00:00'. time_mi_time()
returns an Interval whose time field is the raw difference.
Look at it yourself
The catalog rows make the layout explicit:
SELECT typname, typlen, typbyval, typalign, typarray
FROM pg_type
WHERE typname IN ('date', 'time', 'timetz');pageinspect will show you the exact byte counts — 8 for time, 12 for
timetz:
CREATE EXTENSION pageinspect;
CREATE TABLE t(tt time, tz timetz);
INSERT INTO t VALUES ('12:34:56.789', '12:34:56+02');
SELECT s.t_data
FROM heap_page_items(get_raw_page('t', 0)) h,
LATERAL unnest(tuple_data_split('t'::regclass, h.t_data,
h.t_infomask, h.t_infomask2,
h.t_bits)) AS s(t_data);The zone sign is visible in output, and the precision rounding is visible too:
SELECT '12:34:56.789'::timetz; -- 12:34:56.789+00
SELECT '12:00:00.1235'::time(3); -- 12:00:00.124
SELECT '24:00:00'::time; -- 24:00:00What it all means
timeisint64microseconds since midnight;timetzadds a 4-bytezoneoffset in seconds.timeistyplen 8,typbyval t,typalign d;timetzistyplen 12,typbyval f,typalign d.- The
TimeTzADTstruct is 16 bytes with padding, but only 12 bytes are stored in a tuple; the padding is dropped and the pointer reads back into the first 12 bytes. - Precision (typmod) rounds half-up, capped at 6 digits.
timetzcomparison normalizes to UTC time-of-day, then breaks ties by raw zone, so equality requires both to match.timearithmetic wraps modulo one day, not modulo 24:00:00 as an instant.
Further reading
src/include/catalog/pg_type.dat— thetimeandtimetzentries.src/include/utils/date.h—TimeADT,TimeTzADT,TIMETZ_TYPLEN.src/include/datatype/timestamp.h—USECS_PER_*,TZDISP_LIMIT.src/backend/utils/adt/date.c—tm2time,time2tm,timetz_in/out/recv/send,timetz_cmp_internal,AdjustTimeForTypmod.src/backend/utils/adt/datetime.c—DecodeTimeOnly,EncodeTimeOnly,EncodeTimezone.src/backend/access/common/heaptuple.c—fill_val, fixed-length by-reference storage.