PostgreSQL's macaddr and macaddr8 Implementation in C

macaddr is six bytes and macaddr8 is eight, both pass-by-reference and both declared in the inet header rather than their own. The eight-byte type quietly rewrites six-byte input by inserting FF FE — and that one detail is why the two types interoperate.

A MAC address is the simplest thing in this series: six bytes. PostgreSQL’s macaddr type is, quite literally, a struct of six unsigned chars. And yet the eight-byte sibling macaddr8 has one of the more interesting input routines in the tree, because it accepts both EUI-48 (six-byte) and EUI-64 (eight-byte) input, normalizes everything to EUI-64, and does so with a hand-written hex scanner rather than sscanf.

There is also a small surprise in the headers: there is no utils/mac.h. Both macaddr and macaddr8 are declared in src/include/utils/inet.h, right after inet_struct. They are grouped with the network types because they are, in the catalog’s eyes, members of the same family.

This is a walk through both types, based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The structs

From src/include/utils/inet.h:

c
typedef struct macaddr
{
	unsigned char a;
	unsigned char b;
	unsigned char c;
	unsigned char d;
	unsigned char e;
	unsigned char f;
} macaddr;

typedef struct macaddr8
{
	unsigned char a;
	unsigned char b;
	unsigned char c;
	unsigned char d;
	unsigned char e;
	unsigned char f;
	unsigned char g;
	unsigned char h;
} macaddr8;

No varlena header, no length field, no padding. Six bytes and eight bytes, in transmission order.

Catalog identity

From src/include/catalog/pg_type.dat:

TypeOIDArray OIDtyplentypbyvaltypaligntypcategorytypstorage
macaddr82910406fiUp (default)
macaddr87747758fiUp (default)

Both are fixed-length pass-by-reference (typlen > 0, typbyval = 'f'), and both are typalign = 'i'. That last one is worth pausing on: macaddr is six bytes but is aligned to a 4-byte boundary, not a 2-byte one. typalign describes the alignment of the start of a datum, not its size, so a 6-byte value is placed so that its first byte sits at a 4-byte offset. The value is then copied inline by reference — no pointer chasing, no TOAST.

Neither entry specifies typstorage, so both take the catalog default 'p' (PLAIN) defined in src/include/catalog/pg_type.h. They are not toastable; there is nothing to toast.

Reading and writing

macaddr_in() is the sscanf approach, and it accepts a lot of shapes. It tries seven format strings in sequence:

c
count = sscanf(str, "%x:%x:%x:%x:%x:%x%1s", ...);
if (count != 6)
	count = sscanf(str, "%x-%x-%x-%x-%x-%x%1s", ...);
if (count != 6)
	count = sscanf(str, "%2x%2x%2x:%2x%2x%2x%1s", ...);
if (count != 6)
	count = sscanf(str, "%2x%2x%2x-%2x%2x%2x%1s", ...);
if (count != 6)
	count = sscanf(str, "%2x%2x.%2x%2x.%2x%2x%1s", ...);
if (count != 6)
	count = sscanf(str, "%2x%2x-%2x%2x-%2x%2x%1s", ...);
if (count != 6)
	count = sscanf(str, "%2x%2x%2x%2x%2x%2x%1s", ...);

The %1s at the end is the garbage detector: it only matches if there is trailing non-whitespace, which forces count to be 7 and fails the test. Every octet is then range-checked against 255 before the struct is filled. Output is unforgiving: macaddr_out() prints exactly %02x:%02x:%02x:%02x:%02x:%02x.

macaddr8_in() does not use sscanf. It walks the string two characters at a time, decoding each byte through a 128-entry lookup table:

c
static const signed char hexlookup[128] = {
	-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
	...
	0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1,
	-1, 10, 11, 12, 13, 14, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1,
	...
};

hex2_to_uchar() looks up two characters, sets a badhex flag on anything that is not a hex digit (or beyond the table), and the caller enforces a consistent separator: the first : or - or . seen is remembered in a spacer variable, and any later separator that differs is rejected. A six- or eight-byte parse is allowed, with optional trailing whitespace; anything else fails.

Then comes the interesting part:

c
/* Convert a 6 byte MAC address to macaddr8 */
if (count == 6)
{
	h = f;
	g = e;
	f = d;

	d = 0xFF;
	e = 0xFE;
}
else if (count != 8)
	goto fail;

A six-byte address is expanded into eight bytes by inserting FF FE in the middle. This is the modified-EUI-64 convention used for IPv6 interface identifiers: take the 48-bit address, split it after the 24-bit OUI, and insert FF FE between the two halves. So 08:00:2b:01:02:03 becomes 08:00:2b:ff:fe:01:02:03. macaddr8_recv() does the same thing on the binary path, checking buf->len == 6 before deciding whether to read or synthesize the middle two bytes.

Output is always eight bytes (macaddr8_out()), so a six-byte input does not round-trip textually — it round-trips semantically, to the same eight-byte value.

Comparison and hashing

Both types compare with the same two-macro split. For macaddr:

c
#define hibits(addr) \
  ((unsigned long)(((addr)->a<<16)|((addr)->b<<8)|((addr)->c)))

#define lobits(addr) \
  ((unsigned long)(((addr)->d<<16)|((addr)->e<<8)|((addr)->f)))

macaddr_cmp_internal() compares hibits first, then lobits, giving a plain big-endian byte ordering. macaddr8 uses the same idea with four-byte halves (a<<24 | b<<16 | c<<8 | d and e<<24 | f<<16 | g<<8 | h). Hashing is hash_any() over the raw struct — sizeof(macaddr) or sizeof(macaddr8) bytes.

macaddr also has a SortSupport abbreviated-key converter, and it is unusually simple because the whole value fits in a Datum:

c
StaticAssertDecl(sizeof(res) >= sizeof(macaddr),
				 "Datum is too small for macaddr");
memset(&res, 0, sizeof(res));
memcpy(&res, authoritative, sizeof(macaddr));
...
res = DatumBigEndianToNative(res);

The six bytes are zero-padded into an eight-byte Datum, byteswapped on little-endian machines, and compared as an unsigned integer. Because the abbreviation is lossless, macaddr_abbrev_abort() is a one-liner that always returns false — there is no data distribution for which the abbreviated key would be wrong. macaddr8 has no SortSupport function at all — there is no macaddr8_sortsupport anywhere in the tree — so eight-byte values go through the default comparator.

The bit-twiddling helpers

A handful of helpers exist purely to manipulate the structure of a MAC address:

macaddr_trunc() / macaddr8_trunc() zero everything after the first three bytes, leaving the OUI — “truncation function to allow comparing mac manufacturers”, per the comment. The truncation keeps three bytes even for macaddr8, because the comment in mac8.c states the assumption plainly: “The following code is written with the assumption that the OUI field size is 24 bits.”

macaddr8_set7bit() sets the universal/local bit used by modified EUI-64:

c
result->a = addr->a | 0x02;

Only the second bit of the first byte changes; everything else is copied.

The two conversions between the types are not symmetric. macaddrtomacaddr8() always succeeds, inserting FF FE. macaddr8tomacaddr() refuses unless the fourth and fifth bytes are exactly FF and FE, and the error hint spells out why:

c
if ((addr->d != 0xFF) || (addr->e != 0xFE))
	ereturn(fcinfo->context, (Datum) 0,
			(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
			 errmsg("macaddr8 data out of range to convert to macaddr"),
			 errhint("Only addresses that have FF and FE as values in the "
					 "4th and 5th bytes from the left, ...")));

The FF FE marker is what makes the conversion reversible: without it, there is no way to know which eight-byte value corresponds to a genuine six-byte address.

Look at it yourself

sql
-- all of these are the same six bytes
SELECT '08:00:2b:01:02:03'::macaddr,
       '08002b:010203'::macaddr,
       '0800.2b01.0203'::macaddr,
       '08-00-2b-01-02-03'::macaddr;

-- six-byte input to macaddr8 is rewritten into EUI-64
SELECT '08:00:2b:01:02:03'::macaddr8;   -- 08:00:2b:ff:fe:01:02:03

-- ...and the round trip only works when the FF FE marker is present
SELECT '08:00:2b:ff:fe:01:02:03'::macaddr8::macaddr;  -- 08:00:2b:01:02:03
SELECT '08:00:2b:01:02:03:04:05'::macaddr8::macaddr;  -- ERROR

-- OUI truncation and the modified-EUI-64 bit
SELECT macaddr_trunc('08:00:2b:01:02:03'::macaddr);       -- 08:00:2b:00:00:00
SELECT macaddr8_set7bit('08:00:2b:ff:fe:01:02:03'::macaddr8); -- 0a:00:2b:...

-- fixed-length, pass-by-reference: the size is just typlen
SELECT pg_column_size('08:00:2b:01:02:03'::macaddr),        -- 6
       pg_column_size('08:00:2b:ff:fe:01:02:03'::macaddr8); -- 8

Key takeaways

  • macaddr is a 6-byte struct of unsigned chars; macaddr8 is the same with two more. Both are fixed-length pass-by-reference, declared in src/include/utils/inet.h, not a mac.h.
  • They are aligned to 'i' (4 bytes) despite being 6 and 8 bytes long, because typalign is about the start of the datum.
  • macaddr8 accepts EUI-48 and EUI-64 input and normalizes to EUI-64 by inserting FF FE; conversion back to macaddr requires exactly that marker.
  • macaddr has a lossless SortSupport abbreviation (the whole value fits in a Datum), so abbreviation is never aborted; macaddr8 has none.
  • trunc() keeps the 24-bit OUI; set7bit() flips the universal/local bit for modified EUI-64.

Further reading

  • src/backend/utils/adt/mac.c — the six-byte type: parsing, comparison, hashing, bitwise ops, truncation, SortSupport.
  • src/backend/utils/adt/mac8.c — the eight-byte type: the hex scanner, EUI-48 to EUI-64 expansion, set7bit, and the type conversions.
  • src/include/utils/inet.h — macaddr and macaddr8, plus the fmgr DatumGet/GetDatum macros.
  • src/include/catalog/pg_type.dat — the macaddr (829) and macaddr8 (774) entries.
  • src/include/catalog/pg_type.h — the typalign and typstorage defaults.

PostgresInternalsMacaddrMacaddr8