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:
{ 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:
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:
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:
/* 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:
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):
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”:
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:
/*
* 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:
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:
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:
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
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 rangeThe catalog side is just as easy to inspect:
SELECT typname, typlen, typbyval, typalign FROM pg_type
WHERE typname IN ('int2','int4','int8') ORDER BY typlen;What it all means
int2/int4/int8are three widths of one design: fixed-length,typbyval, plain-storageDatums.- 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_*_overflowhelpers so overflow raisesERRCODE_NUMERIC_VALUE_OUT_OF_RANGErather than wrapping. INT_MIN / -1and% -1are special-cased because two’s complement makes the naive result undefined.
Further reading
src/backend/utils/adt/int.c—int2/int4I/O, casts, arithmetic.src/backend/utils/adt/int8.c—int8I/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*_overflowand the comparison helpers.src/backend/access/nbtree/nbtcompare.c—btint2cmp/btint4cmp/btint8cmp.