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:
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:
| Type | OID | Array OID | typlen | typbyval | typalign | typcategory | typstorage |
|---|---|---|---|---|---|---|---|
macaddr | 829 | 1040 | 6 | f | i | U | p (default) |
macaddr8 | 774 | 775 | 8 | f | i | U | p (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:
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:
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:
/* 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:
#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:
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:
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:
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
-- 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); -- 8Key takeaways
macaddris a 6-byte struct ofunsigned chars;macaddr8is the same with two more. Both are fixed-length pass-by-reference, declared insrc/include/utils/inet.h, not amac.h.- They are aligned to
'i'(4 bytes) despite being 6 and 8 bytes long, becausetypalignis about the start of the datum. macaddr8accepts EUI-48 and EUI-64 input and normalizes to EUI-64 by insertingFF FE; conversion back tomacaddrrequires exactly that marker.macaddrhas a lossless SortSupport abbreviation (the whole value fits in aDatum), so abbreviation is never aborted;macaddr8has 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—macaddrandmacaddr8, plus the fmgrDatumGet/GetDatummacros.src/include/catalog/pg_type.dat— themacaddr(829) andmacaddr8(774) entries.src/include/catalog/pg_type.h— thetypalignandtypstoragedefaults.