How PostgreSQL Implements inet and cidr in C
inet and cidr are the same 18-byte struct — a family byte, a mask-length byte, and sixteen address bytes — behind a varlena header. The only differences are one input-time check and a normalization step.
Most systems store an IP address as four or sixteen bytes and leave the netmask somewhere else. PostgreSQL does something a little different: it puts the address, the family, and the mask length into a single value, wraps the whole thing in a varlena header, and then uses that same value for two SQL types that behave very differently at the edges.
inet is a host address with an optional netmask. cidr is a network — it
refuses to store bits to the right of the mask. They share one struct, one set
of comparison functions, one hash function, and one pair of I/O routines. The
difference between them is almost entirely a single check at input time.
This is a walk through how that works, based on reading the PostgreSQL source
(version 20devel, commit e75fcc9867f).
One struct, two types
The representation lives in src/include/utils/inet.h:
typedef struct
{
unsigned char family; /* PGSQL_AF_INET or PGSQL_AF_INET6 */
unsigned char bits; /* number of bits in netmask */
unsigned char ipaddr[16]; /* up to 128 bits of address */
} inet_struct;ipaddr is always 16 bytes, even for IPv4. The struct is a fixed shape; the
variable part is the varlena length, which is computed from the family:
typedef struct
{
char vl_len_[4]; /* Do not touch this field directly! */
inet_struct inet_data;
} inet;inet_struct is 18 bytes, so an inet value never exceeds 22 bytes with a
4-byte header. The accessors never touch vl_len_ directly; they reach through
the varlena machinery with VARDATA_ANY:
#define ip_family(inetptr) \
(((inet_struct *) VARDATA_ANY(inetptr))->family)
#define ip_bits(inetptr) \
(((inet_struct *) VARDATA_ANY(inetptr))->bits)
#define ip_addrsize(inetptr) \
(ip_family(inetptr) == PGSQL_AF_INET ? 4 : 16)
#define ip_maxbits(inetptr) \
(ip_family(inetptr) == PGSQL_AF_INET ? 32 : 128)
#define SET_INET_VARSIZE(dst) \
SET_VARSIZE(dst, VARHDRSZ + offsetof(inet_struct, ipaddr) + \
ip_addrsize(dst))SET_INET_VARSIZE is the tell: an IPv4 value is 4 + 2 + 4 = 10 bytes and an
IPv6 value is 4 + 2 + 16 = 22. The struct is one size; the datum is not.
There is a subtlety in VARDATA_ANY worth knowing, and the header comment
explains it: VARDATA_ANY assumes the header is already valid, but a freshly
allocated value has not had SET_INET_VARSIZE called yet. So every constructor
uses palloc0_object(inet) — a zeroed allocation. A zero length word is not
mistaken for a short varlena, so the macros work while the value is being built.
Catalog identity
From src/include/catalog/pg_type.dat:
| Type | OID | Array OID | typlen | typbyval | typalign | typstorage | typcategory |
|---|---|---|---|---|---|---|---|
inet | 869 | 1041 | -1 | f | i | m | I |
cidr | 650 | 651 | -1 | f | i | m | I |
Both are variable-length (typlen = -1), pass-by-reference, and 4-byte aligned.
The interesting field is typstorage => 'm' — TYPSTORAGE_MAIN. Most varlena
types are 'x' (EXTENDED, “fully toastable”); 'm' is the same except that
TOAST tries to keep the value in the main tuple. For an 18-byte value that is a
formality, but it is deliberate: network addresses are small and should not be
pushed out of line. inet also carries typispreferred => 't', so it wins the
implicit-cast tiebreak inside category I (network address types).
What the bytes look like
An IPv4 value on a default (MAIN) column, in the short varlena form:
+--------+--------+--------+------------------------+
| 1-byte | family | bits | ipaddr[0..3] |
| header | (1) | (1) | (4 bytes, MSB first) |
+--------+--------+--------+------------------------+
total: 7 bytesAn IPv6 value is 19 bytes: the same two bytes, then 16 address bytes.
The header comment in inet.h claims that inet/cidr are “invariably” stored
using the 1-byte-header format. For the default storage setting that is true, but
only because of the packed-varlena work: heap_form_tuple() converts to the
short form only when the column is “packable”, defined in
src/backend/access/common/heaptuple.c as
#define ATT_IS_PACKABLE(att) \
((att)->attlen == -1 && (att)->attstorage != TYPSTORAGE_PLAIN)Declare the column STORAGE PLAIN and attstorage becomes 'p', the value
keeps its 4-byte header, and it stops being toastable at all. The header
comment predates that mechanism.
Input, output, and the one difference
Both types share network_in():
if (strchr(src, ':') != NULL)
ip_family(dst) = PGSQL_AF_INET6;
else
ip_family(dst) = PGSQL_AF_INET;
bits = pg_inet_net_pton(ip_family(dst), src, ip_addr(dst),
is_cidr ? ip_addrsize(dst) : -1);The family is guessed from the presence of a colon, and the actual parsing is
delegated to pg_inet_net_pton() in
src/backend/utils/adt/inet_net_pton.c — a Paul Vixie routine that returns the
number of bits in the prefix. For inet it is called with a size of -1,
which selects inet_net_pton_ipv4(). That function defaults the prefix to
/32, but only if all four octets were written; '192.168.0' is an error.
For cidr it is called with a real size, selecting inet_cidr_pton_ipv4(),
which will impute a classful mask when no /n is given and then widen it to
cover every octet that was actually written. Then the check that makes cidr
what it is:
if (is_cidr)
{
if (!addressOK(ip_addr(dst), bits, ip_family(dst)))
ereturn(escontext, NULL,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid cidr value: \"%s\"", src),
errdetail("Value has bits set to right of mask.")));
}addressOK() walks from the mask boundary and returns false if any bit to the
right is set. That is the whole inet-versus-cidr distinction on input. The
other normalization point is cidr_set_masklen_internal(), which copies only
(bits + 7) / 8 address bytes and clears the leftover bits in the final partial
byte, so a cidr value can never acquire stray host bits after construction.
Output is symmetric. network_out() calls pg_inet_net_ntop() in
src/port/inet_net_ntop.c and, for cidr, appends /n if it is missing. Note
that the ntop routine omits /32 and /128 — which is why '192.168.0.1'::inet
prints without a suffix. The text cast uses a separate function,
network_show(), which always appends the mask length, so ::text and the
default output can differ.
The binary (wire) format (see the comment above network_recv()) is one byte
each of family, bits, is_cidr, and address length, then the address in network
byte order. The is_cidr byte is written correctly but ignored on input —
“largely for historical reasons”.
Comparison, and the abbreviated-key trick
All six comparison operators funnel into one function:
static int32
network_cmp_internal(inet *a1, inet *a2)
{
if (ip_family(a1) == ip_family(a2))
{
int order;
order = bitncmp(ip_addr(a1), ip_addr(a2),
Min(ip_bits(a1), ip_bits(a2)));
if (order != 0)
return order;
order = ((int) ip_bits(a1)) - ((int) ip_bits(a2));
if (order != 0)
return order;
return bitncmp(ip_addr(a1), ip_addr(a2), ip_maxbits(a1));
}
return ip_family(a1) - ip_family(a2);
}The order is: family first (IPv4 before IPv6, since PGSQL_AF_INET is
AF_INET + 0 and PGSQL_AF_INET6 is AF_INET + 1), then the network part, then
the mask length, then the host part. bitncmp() is a bit-granular memcmp:
whole bytes via memcmp, then a hand-rolled loop over the bits of the final
partial byte.
Sorting gets an extra layer. network_sortsupport() installs an abbreviated-key
converter, and the comment above network_abbrev_convert() lays out the packing
for IPv4:
+----------+-----------------------+--------------+--------------------+
| 1 bit IP | 32 bits network | 6 bits | 25 bits subnet |
| family | (full) | network size | (truncated) |
+----------+-----------------------+--------------+--------------------+All 32 network bits, all 6 mask-size bits, and the top 25 subnet bits are packed
into a single Datum, so a fast unsigned-integer comparison reproduces
network_cmp_internal()’s ordering exactly. A HyperLogLog estimator watches the
cardinality and aborts abbreviation if the data is too repetitive to benefit
(network_abbrev_abort()).
Design notes
The family byte is deliberately not the OS constant. PGSQL_AF_INET6 is
defined as AF_INET + 1, not AF_INET6, because the header says referencing
the non-AF_INET families to AF_INET “lets us work on machines which did not
have the appropriate address family … but didn’t cause a dump/reload
requirement”. The on-disk value is stable even when the platform’s constants
are not.
The hash function hashes the payload, not the header. hashinet() does
return hash_any((unsigned char *) VARDATA_ANY(addr), addrsize + 2);— family plus bits plus address. It skips the varlena header entirely, so the
same logical address hashes the same whether it is stored short or with a 4-byte
header. The accompanying comment (“XXX this assumes there are no pad bytes in
the data structure”) holds because every field of inet_struct is a byte, so
the struct has no padding.
Selectivity estimation is approximate on purpose. convert_network_to_scalar()
in the same file folds an address into a double, but uses only the first four
address bytes for IPv4 and the first five for IPv6, with the comment “we don’t
use the full address for IPv6”.
Look at it yourself
-- what the catalog says
SELECT typname, typlen, typbyval, typalign, typstorage, typarray
FROM pg_type WHERE typname IN ('inet', 'cidr');
-- inet keeps the host part; cidr refuses it
SELECT '192.168.0.1/24'::inet; -- 192.168.0.1/24
SELECT '192.168.0.1/24'::cidr; -- ERROR: invalid cidr value
-- the accessors are plain SQL functions
SELECT family('192.168.0.1'::inet), -- 4
masklen('192.168.0.1/24'::inet), -- 24
host('192.168.0.1/24'::inet), -- 192.168.0.1
network('192.168.0.1/24'::inet); -- 192.168.0.0/24
-- the stored size exposes the short varlena header
CREATE TEMP TABLE n (a inet, b cidr);
INSERT INTO n VALUES ('192.168.0.1', '192.168.0.0/24'), ('::1', '::/0');
SELECT pg_column_size(a), pg_column_size(b) FROM n; -- 7, 7 then 19, 19The last query is the one to remember: the cidr and inet columns have
identical storage, because they are the same type underneath.
Key takeaways
inetandcidrare oneinet_struct—family,bits,ipaddr[16]— wrapped in a varlena header; the family decides whether the datum is 10 or 22 bytes.- The mask length is stored inside the value as a byte, not derived.
cidrdiffers frominetby exactly one input check (addressOK) plus a normalization step (cidr_set_masklen_internal); everything else is shared.- Both are
typstorage = 'm'(MAIN), and on a default column they are stored in the 1-byte-header varlena format. - Comparison is family, then network bits, then mask length, then host bits; the SortSupport abbreviated key packs IPv4 into 64 bits to make that ordering a single integer compare.
Further reading
src/include/utils/inet.h—inet_struct,inet, and the access macros.src/backend/utils/adt/network.c— input/output, comparison, hashing, SortSupport, and the network functions.src/backend/utils/adt/inet_net_pton.c— Vixie’s presentation-to-network parser, including classful mask imputation.src/port/inet_net_ntop.c— the reverse direction, and why/32disappears.src/include/catalog/pg_type.dat— theinet(869) andcidr(650) entries.src/backend/access/common/heaptuple.c—ATT_IS_PACKABLEand the short varlena conversion.