PostgreSQL's Integer Type Implementation in C (int2, int4, int8)

int2, int4, and int8 are one design in three widths: a fixed-length pass-by-value Datum, a parser that accepts decimal and hex/octal/binary, and arithmetic wrapped in overflow-checked helpers so a SQL expression never silently wraps.

PostgreSQL has three signed integer widths: int2 (2 bytes), int4 (4 bytes), and int8 (8 bytes). They are not three separate designs. They are one design instantiated three times: a fixed-length, pass-by-value Datum holding a plain C integer, with a text parser that accepts decimal and hex/octal/ binary, a text formatter, a binary send/recv pair, and an entire layer of overflow-checked arithmetic so that SELECT 2147483647 + 1 raises an error instead of wrapping to a negative number.

This is based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The catalog entries

Three entries in src/include/catalog/pg_type.dat — OIDs 21, 23, and 20:

text
{ oid => '21', array_type_oid => '1005',
  typname => 'int2', typlen => '2', typbyval => 't', typcategory => 'N',
  typinput => 'int2in', typoutput => 'int2out', typreceive => 'int2recv',
  typsend => 'int2send', typalign => 's' },
{ oid => '23', array_type_oid => '1007',
  typname => 'int4', typlen => '4', typbyval => 't', typcategory => 'N',
  typinput => 'int4in', typoutput => 'int4out', typreceive => 'int4recv',
  typsend => 'int4send', typalign => 'i' },
{ oid => '20', array_type_oid => '1016',
  typname => 'int8', typlen => '8', typbyval => 't', typcategory => 'N',
  typinput => 'int8in', typoutput => 'int8out', typreceive => 'int8recv',
  typsend => 'int8send', typalign => 'd' },

The only differences are the widths and the alignment code. typalign is 's' (TYPALIGN_SHORT, 2 bytes), 'i' (TYPALIGN_INT, 4 bytes), and 'd' (TYPALIGN_DOUBLE, 8 bytes). All three are typbyval => 't', so a value is stored inline in the tuple, never as a pointer. All three take the default typstorage => 'p' (TYPSTORAGE_PLAIN): integers are never TOASTed.

There is also int2vector (OID 22), a variable-length array of int2 used inside system catalogs. It is a real type, not just int2[], and it has its own parser (int2vectorin) and its own layout rules.

The C representation

The three types are just C int16, int32, and int64. The Datum conversions in src/include/postgres.h are one-line casts:

c
static inline int16
DatumGetInt16(Datum X) { return (int16) X; }

static inline int32
DatumGetInt32(Datum X) { return (int32) X; }

static inline int64
DatumGetInt64(Datum X) { return (int64) X; }

Because all three are typbyval, the value travels inside the Datum itself. An int8 is therefore 8 bytes on disk and 8 bytes in a register, with no indirection. The int2 and int4 forms are promoted to a wider C type whenever they are loaded, so arithmetic rarely operates on a 2-byte value.

Input: one parser, four radixes

int2in, int4in, and int8in are thin wrappers over three sibling functions in src/backend/utils/adt/numutils.c, declared in src/include/utils/builtins.h:

c
PG_RETURN_INT16(pg_strtoint16_safe(num, fcinfo->context));
PG_RETURN_INT32(pg_strtoint32_safe(num, fcinfo->context));
PG_RETURN_INT64(pg_strtoint64_safe(num, fcinfo->context));

The parser has two paths. A fast path handles plain decimal; anything else falls through to a slow path that understands 0x/0X hex, 0o/0O octal, 0b/0B binary, and _ digit separators. Here is the fast path’s core, from pg_strtoint64_safe:

c
	/* process remaining digits */
	for (;;)
	{
		digit = (*ptr - '0');

		if (digit >= 10)
			break;

		ptr++;

		if (unlikely(tmp > -(PG_INT64_MIN / 10)))
			goto out_of_range;

		tmp = tmp * 10 + digit;
	}

The header comment explains the trick: “Accumulate input as an unsigned number, to deal with two’s complement representation of the most negative number, which can’t be represented as a signed positive number.” So the accumulator is uint64, and the sign is applied at the end via pg_neg_u64_overflow. That is what lets -9223372036854775808 parse without special-casing the minimum value.

So SELECT '0x10'::int4 is 16, SELECT '0b101'::int4 is 5, and SELECT '1_000'::int4 is 1000 — the same parser backs all three widths.

Output: fixed-size buffers

The output functions format into a caller-sized buffer, then hand out a NUL-terminated string. int2out delegates to pg_ltoa through pg_itoa:

c
Datum
int2out(PG_FUNCTION_ARGS)
{
	int16		arg1 = PG_GETARG_INT16(0);
	char	   *result = (char *) palloc(7);	/* sign, 5 digits, '\0' */

	pg_itoa(arg1, result);
	PG_RETURN_CSTRING(result);
}

int4out calls pg_ltoa directly, and int8out calls pg_lltoa, which first formats the magnitude as unsigned and then prepends the sign. The buffers are sized for the worst case: 7 bytes for int2, 12 for int4 (MAXINT8LEN is 20, plus a sign and NUL, for int8).

Binary format

The send/recv pair is the same shape for all three. int8recv and int8send (src/backend/utils/adt/int8.c):

c
Datum
int8recv(PG_FUNCTION_ARGS)
{
	StringInfo	buf = (StringInfo) PG_GETARG_POINTER(0);

	PG_RETURN_INT64(pq_getmsgint64(buf));
}

Datum
int8send(PG_FUNCTION_ARGS)
{
	int64		arg1 = PG_GETARG_INT64(0);
	StringInfoData buf;

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

int2recv uses pq_getmsgint(buf, sizeof(int16)) and int4recv uses pq_getmsgint(buf, sizeof(int32)) — the length is passed explicitly so the wire width matches the type. Big-endian integers are byte-swapped by the pq_send* helpers, so the binary format is platform-independent.

Arithmetic: the part that is actually hard

Addition, subtraction, and multiplication do not do raw a + b. They call overflow-checked helpers in src/include/common/int.h, which are explicitly written to be “well defined C, without relying on compiler flags like -fwrapv”:

c
Datum
int4pl(PG_FUNCTION_ARGS)
{
	int32		arg1 = PG_GETARG_INT32(0);
	int32		arg2 = PG_GETARG_INT32(1);
	int32		result;

	if (unlikely(pg_add_s32_overflow(arg1, arg2, &result)))
		ereport(ERROR,
				(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
				 errmsg("integer out of range")));
	PG_RETURN_INT32(result);
}

pg_add_s16_overflow, pg_add_s32_overflow, and pg_add_s64_overflow use __builtin_add_overflow when available, widen to a larger type otherwise, and return true on overflow. The same pattern guards pg_sub_*, pg_mul_*, and pg_neg_*, so int2pl, int2mi, int2mul, int4pl, and their int8 equivalents all raise ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE instead of wrapping.

Division has a special case because two’s complement cannot represent the result of INT_MIN / -1:

c
	/*
	 * INT64_MIN / -1 is problematic, since the result can't be represented on a
	 * two's-complement machine.  Some machines produce INT64_MIN, some produce
	 * zero, some throw an exception.  We can dodge the problem by recognizing
	 * that division by -1 is the same as negation.
	 */
	if (arg2 == -1)
	{
		if (pg_neg_s64_overflow(arg1, &result))
			ereport(ERROR,
					(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
					 errmsg("bigint out of range")));
		PG_RETURN_INT64(result);
	}

The same comment appears in int4div and int2div. int8mod returns 0 directly for % -1 (the mathematically correct answer), and int8abs refuses PG_INT64_MIN because its absolute value cannot fit.

Casts and cross-type operators

There are explicit casts between the widths and to bool. i2toi4 widens without checking; i4toi2 narrows with a range check:

c
Datum
i4toi2(PG_FUNCTION_ARGS)
{
	int32		arg1 = PG_GETARG_INT32(0);

	if (unlikely(arg1 < SHRT_MIN) || unlikely(arg1 > SHRT_MAX))
		ereturn(fcinfo->context, (Datum) 0,
				(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
				 errmsg("smallint out of range")));

	PG_RETURN_INT16((int16) arg1);
}

Comparisons between different widths live in int8.c (int84eq, int48eq, int82eq, int28eq, …) rather than being generated, and the catalog carries separate operator entries for each pair.

btree ordering

For indexes, the comparison must return an int32 sign. btint2cmp can subtract because an int16 difference fits in an int32:

c
Datum
btint2cmp(PG_FUNCTION_ARGS)
{
	int16		a = PG_GETARG_INT16(0);
	int16		b = PG_GETARG_INT16(1);

	PG_RETURN_INT32((int32) a - (int32) b);
}

btint4cmp and btint8cmp cannot subtract safely and use explicit comparisons instead:

c
Datum
btint8cmp(PG_FUNCTION_ARGS)
{
	int64		a = PG_GETARG_INT64(0);
	int64		b = PG_GETARG_INT64(1);

	if (a > b)
		PG_RETURN_INT32(A_GREATER_THAN_B);
	else if (a == b)
		PG_RETURN_INT32(0);
	else
		PG_RETURN_INT32(A_LESS_THAN_B);
}

The header of src/backend/access/nbtree/nbtcompare.c spells out the invariant: “it is critical that the comparison function impose a total order on all non-NULL values of the data type, and that the datatype’s boolean comparison operators yield results consistent with the comparison routine.” That is why these small functions exist at all.

Look at it yourself

sql
SELECT '0x10'::int4, '0b101'::int4, '1_000'::int4;   -- 16, 5, 1000
SELECT 32767::int2 + 1;                             -- ERROR: smallint out of range
SELECT 2147483647 + 1;                              -- ERROR: integer out of range
SELECT 9223372036854775807::int8 + 1;               -- ERROR: bigint out of range
SELECT int8 '-9223372036854775808' / -1;           -- ERROR: bigint out of range
SELECT 2147483647::int4::int2;                      -- ERROR: smallint out of range

The catalog side is just as easy to inspect:

sql
SELECT typname, typlen, typbyval, typalign FROM pg_type
WHERE typname IN ('int2','int4','int8') ORDER BY typlen;

What it all means

  • int2/int4/int8 are three widths of one design: fixed-length, typbyval, plain-storage Datums.
  • The alignment codes (s/i/d) control tuple padding, not value semantics.
  • One parser (pg_strtoint*_safe) backs all three widths and accepts decimal, hex, octal, binary, underscores, and a leading +/-.
  • Arithmetic is wrapped in pg_*_overflow helpers so overflow raises ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE rather than wrapping.
  • INT_MIN / -1 and % -1 are special-cased because two’s complement makes the naive result undefined.

Further reading

  • src/backend/utils/adt/int.c — int2/int4 I/O, casts, arithmetic.
  • src/backend/utils/adt/int8.c — int8 I/O and cross-type operators.
  • src/backend/utils/adt/numutils.c — pg_strtoint*_safe, pg_itoa, pg_ltoa, pg_lltoa.
  • src/include/common/int.h — pg_add_s*_overflow and the comparison helpers.
  • src/backend/access/nbtree/nbtcompare.c — btint2cmp/btint4cmp/btint8cmp.

PostgresInternalsInt2Int4Int8