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:

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

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

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

TypeOIDArray OIDtyplentypbyvaltypaligntypstoragetypcategory
inet8691041-1fimI
cidr650651-1fimI

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:

text
+--------+--------+--------+------------------------+
| 1-byte | family | bits   | ipaddr[0..3]           |
| header | (1)    | (1)    | (4 bytes, MSB first)   |
+--------+--------+--------+------------------------+
   total: 7 bytes

An 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

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

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

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

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

text
+----------+-----------------------+--------------+--------------------+
| 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

c
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

sql
-- 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, 19

The last query is the one to remember: the cidr and inet columns have identical storage, because they are the same type underneath.

Key takeaways

  • inet and cidr are one inet_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.
  • cidr differs from inet by 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 /32 disappears.
  • src/include/catalog/pg_type.dat — the inet (869) and cidr (650) entries.
  • src/backend/access/common/heaptuple.c — ATT_IS_PACKABLE and the short varlena conversion.

PostgresInternalsInetCIDR