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):

text
{ 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:

Fieldtimetimetz
typlen812
typbyvalt (by value)f (by reference)
typalignd (8 bytes)d (8 bytes)
array oid11831270

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:

c
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		12

time 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()):

c
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:

c
	/*
	 * 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:

c
	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:

c
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):

c
#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):

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()):

c
	/* 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:

c
	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:

sql
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:

sql
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:

sql
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:00

What it all means

  • time is int64 microseconds since midnight; timetz adds a 4-byte zone offset in seconds.
  • time is typlen 8, typbyval t, typalign d; timetz is typlen 12, typbyval f, typalign d.
  • The TimeTzADT struct 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.
  • timetz comparison normalizes to UTC time-of-day, then breaks ties by raw zone, so equality requires both to match.
  • time arithmetic wraps modulo one day, not modulo 24:00:00 as an instant.

Further reading

  • src/include/catalog/pg_type.dat — the time and timetz entries.
  • 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.

PostgresInternalsTimeTimetz