networking
One IPv6 Address, Eight Ways to Write It
The same 128 bits can be spelled at least eight legal ways, which is why string-comparing IPv6 addresses in logs, allowlists, and databases quietly fails. RFC 5952 picks exactly one canonical form — here are its four rules, worked through real output, plus the two places our expander deliberately departs from it.
Your allowlist contains 2001:DB8::1:0:0:1. The request arrives logged as 2001:db8:0:0:1::1. The comparison fails, the request is rejected, and nothing in either string is wrong. They are the same 128 bits. IPv6 permits leading zeros to be dropped or kept, a run of zero groups to be collapsed or spelled out, and hex digits in either case — so a single address has many legal text forms, and only one of them is canonical.
Here is that address, written eight ways, every one of them valid:
| # | Spelling | What varies |
|---|---|---|
| 1 | 2001:0db8:0000:0000:0001:0000:0000:0001 | The preferred full form |
| 2 | 2001:db8:0:0:1:0:0:1 | Leading zeros dropped |
| 3 | 2001:db8::1:0:0:1 | The RFC 5952 canonical form |
| 4 | 2001:db8:0:0:1::1 | The second zero run collapsed instead |
| 5 | 2001:DB8::1:0:0:1 | Uppercase hex |
| 6 | 2001:0db8::1:0:0:1 | One leading zero kept |
| 7 | 2001:db8:0:0:1:0:0:0001 | Trailing group padded |
| 8 | 2001:db8::0001:0:0:1 | Both, mixed |
Run all eight through the IPv6 expander and they collapse to the same output — 2001:db8::1:0:0:1 compressed, 2001:0db8:0000:0000:0001:0000:0000:0001 expanded. That is the whole job: normalize before you compare, and never compare the strings you were handed.
Where the ambiguity comes from
RFC 4291, the addressing architecture, defines three conventional forms. The first is x:x:x:x:x:x:x:x, eight groups of one to four hex digits, and it notes that “it is not necessary to write the leading zeros in an individual field, but there must be at least one numeral in every field.” That single sentence produces forms 1, 2, 6, 7, and 8 above.
The second form is zero compression: “The use of :: indicates one or more groups of 16 bits of zeros. The :: can only appear once in an address.” Once, but the RFC does not say which run — so an address with two separate zero runs gets forms 3 and 4.
The third form is mixed notation, x:x:x:x:x:x:d.d.d.d, where the last 32 bits are written as a dotted-quad IPv4 address. That gives 0:0:0:0:0:FFFF:129.144.52.38 as an alternative spelling of the same bits as ::ffff:8190:3426.
Three forms, no tiebreaker. Which is why, four years later, a second RFC was written to pick winners.
RFC 5952: four rules that leave exactly one answer
RFC 5952 is short and unusually direct. Its Section 4 is a list of MUSTs for producing text, and following all of them yields a single canonical string per address.
4.1 — Leading zeros MUST be suppressed. “For example, 2001:0db8::0001 is not acceptable and must be represented as 2001:db8::1.” Note this applies per field: 0000 becomes 0, not empty.
4.2.1 — :: MUST be used to its maximum capability. “For example, 2001:db8:0:0:0:0:2:1 must be shortened to 2001:db8::2:1.” Our compressor agrees:
2001:db8:0:0:0:0:2:1 -> 2001:db8::2:1
fe80:0:0:0:0:0:0:1 -> fe80::1
4.2.2 — :: MUST NOT be used to shorten just one 16-bit 0 field. “The representation 2001:db8:0:1:1:1:1:1 is correct, but 2001:db8::1:1:1:1:1 is not correct.” Compressing a single zero group saves one character and costs a reader real effort, so the RFC forbids it. Ours honors that:
2001:db8:0:1:1:1:1:1 -> 2001:db8:0:1:1:1:1:1 (unchanged)
4.2.3 — With equal-length runs, the first one wins. “When the length of the consecutive 16-bit 0 fields are equal (i.e., 2001:db8:0:0:1:0:0:1), the first sequence of zero bits MUST be shortened.” This is the rule that decides between forms 3 and 4 in the table above, and it is the one most naive implementations get wrong — a “find the longest run” loop that updates on >= rather than > silently picks the last run and produces a valid-but-non-canonical string.
4.3 — Hex letters MUST be lowercase. “The characters ‘a’, ‘b’, ‘c’, ‘d’, ‘e’, and ‘f’ in an IPv6 address MUST be represented in lowercase.”
Together: suppress leading zeros, collapse the longest zero run (first one on a tie, never a lone group), lowercase everything. One address, one string.
The two places our tool departs from the spec
Both are worth knowing before you rely on it.
It does not accept or emit mixed IPv4 notation. RFC 5952 §5 says mixed notation is RECOMMENDED for addresses with a well-known IPv4-embedding prefix, and that “the text representation method noted in Section 4 should be applied for the leading hexadecimal part (i.e., ::ffff:192.0.2.1 instead of 0:0:0:0:0:ffff:192.0.2.1).” Ours goes halfway:
0:0:0:0:0:ffff:c000:201 -> ::ffff:c000:201
::ffff:192.0.2.1 -> Invalid group: "192.0.2.1"
The hexadecimal part is compressed correctly, but the low 32 bits stay hex rather than becoming 192.0.2.1, and a dotted-quad on input is rejected outright. If you are working with IPv4-mapped addresses, convert the last two groups by hand: c000:201 is 192.0.2.1 (c0=192, 00=0, 02=2, 01=1).
It does not handle zone identifiers. RFC 4007 §11 defines the <address>%<zone_id> format for scoped addresses — the fe80::1%eth0 you see on link-local interfaces — and RFC 6874 extends URIs to carry one (percent-encoded as %25, so fe80::1%25eth0 inside a URL). Our expander rejects the whole string:
fe80::1%eth0 -> Invalid group: "1%eth0"
Strip the %zone suffix before pasting, and remember it back afterward. The zone is interface-local metadata, not part of the address.
Where the ambiguity actually bites
Log grepping and allowlists. Any comparison of IPv6 addresses as strings is a comparison of spellings, and two sources rarely spell alike. Anything that formats an address through the system’s inet_ntop() gives you a compressed lowercase form; anything written by a person, exported from a spreadsheet, or built by string concatenation in application code may not. Normalize on the way in, not at comparison time.
Database columns. A VARCHAR column will happily store all eight spellings from the table above as eight distinct rows, and a UNIQUE constraint will not save you. Store the address in a real IP type instead — PostgreSQL has inet, MySQL has INET6_ATON() / INET6_NTOA() around a VARBINARY(16) — and the text form stops mattering because you are indexing the 128 bits.
URLs need brackets. RFC 3986 defines the host as IP-literal = "[" ( IPv6address / IPvFuture ) "]", because the colons in an address are indistinguishable from the colon before a port. RFC 5952 §6 lists six spellings people actually use for “address plus port” — [2001:db8::1]:80, 2001:db8::1:80, 2001:db8::1.80, 2001:db8::1 port 80, 2001:db8::1p80, 2001:db8::1#80 — and calls the second “NOT RECOMMENDED because of its ambiguity”, adding that “the [] style as expressed in [RFC3986] SHOULD be employed.” If you are picking a URL apart, the URL parser shows you what the browser’s own parser makes of the brackets.
AAAA records. DNS returns the address, not a spelling you chose. If you are comparing a resolver’s answer to a configured value, normalize both — DNS record types explained covers what AAAA carries, and the DNS lookup tool will show you the live answer.
Prefixes and subnetting
RFC 5952 §7 applies the same Section 4 rules to prefixes, so 2001:db8::/32 is canonical and 2001:0DB8:0000::/32 is not. The arithmetic itself is simpler than IPv4’s: subnetting usually happens on nibble boundaries, a /64 is the standard single-LAN allocation, and RFC 4291 states flatly that “there are no broadcast addresses in IPv6.” The only address a /64 reserves is the Subnet-Router anycast address — the all-zeros interface ID, RFC 4291 §2.6.1 — so the “subtract two for network and broadcast” habit from IPv4 does not carry over.
One honest caveat: our subnet calculator and IP range expander are IPv4 only. They parse dotted-quad addresses and 0–32 prefixes, and will reject an IPv6 input. For IPv6 the expander/compressor is the tool we have; if you want the IPv4 side of this material, CIDR and subnetting explained is the companion piece, and what is my IP will tell you whether your own connection is handing you a v6 address in the first place.
The short version
| The assumption | What actually happens |
|---|---|
| ”Two different IPv6 strings are two different addresses” | At least eight spellings encode identical bits |
”:: collapses the zeros, so there is one answer” | With two equal zero runs, RFC 5952 §4.2.3 requires the first to be collapsed |
”Compressing a single 0 group is fine” | §4.2.2 forbids it — 2001:db8::1:1:1:1:1 is not canonical |
| ”Uppercase hex is a style choice” | §4.3 makes lowercase a MUST |
| ”I can store it in a text column” | Use inet / INET6_ATON or you will store the same host many times |
| ”I can drop it into a URL” | RFC 3986 requires square brackets around the literal |
”fe80::1%eth0 is an address” | The %zone is interface metadata; most parsers, including ours, reject it |