An IPv6 address contains 128 bits, usually written as eight hexadecimal groups. Text may omit leading zeros and replace one consecutive run of zero groups with ::. These rules preserve the address while shortening its spelling. RFC 4291 defines valid forms; RFC 5952 recommends a consistent representation for display and exchange. Valid syntax and the preferred spelling are related but different questions.
When it breaks
Raw string equality can treat one address as several identities. Uppercase letters, leading zeros, and expanded zero groups can all change the text without changing the 128 bits. That can split inventory entries or defeat an application’s intended address comparison. Parse first with a library appropriate to the input, then compare address values instead of assuming lowercasing alone performs full normalization.
Compression also has constraints. :: can appear only once, because two omitted runs would leave their lengths ambiguous. For the recommended representation, compress the longest zero run, choosing the first when lengths tie, and do not compress a single zero field. General IPv6 syntax permits some spellings that these display rules deliberately avoid. A normalizer should not confuse an accepted nonpreferred form with malformed input.
An address is not always a complete endpoint. A URL needs brackets around an IPv6 literal to separate its colons from the port delimiter. A link-local destination may also need a zone identifier to select the intended interface context. Stripping a zone or flattening an IPv4-mapped address without an explicit comparison policy can discard information the caller needs to make the right connection.
Check it quickly
Save this as check-ipv6-text.py and run python3 check-ipv6-text.py with Docker. The pinned Python 3.11.16 image runs standard-library parsing without network traffic. It compares valid variants, rejects malformed inputs, and inspects endpoint components. The test does not assign the example addresses, send IPv6 packets, or establish that any route to them exists:
import json,subprocess,uuid
image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84"
program=r"""
from ipaddress import IPv6Address
from urllib.parse import urlsplit
import json
variants=['2001:0DB8:0000:0000:0000:ff00:0042:8329','2001:db8:0:0:0:ff00:42:8329','2001:db8::ff00:42:8329']
parsed=[IPv6Address(v) for v in variants];assert len(set(parsed))==1 and len(set(variants))==3
tie=IPv6Address('2001:0:0:1:0:0:1:1').compressed;assert tie=='2001::1:0:0:1:1'
single=IPv6Address('2001:db8::1:2:3:4:5').compressed;assert single=='2001:db8:0:1:2:3:4:5'
bad=[]
for text in ['2001::db8::1','2001:db8:1:2:3:4:5:6:7','2001:db8::gg','2001:db8::1/64']:
try:IPv6Address(text);raise AssertionError('invalid address accepted')
except ValueError:bad.append(text)
url=urlsplit('https://[2001:db8::1]:8443/health');assert IPv6Address(url.hostname)==IPv6Address('2001:db8::1') and url.port==8443
try:urlsplit('https://2001:db8::1:8443/health').port;raise AssertionError('unbracketed endpoint accepted')
except ValueError:bad_url=True
mapped=IPv6Address('::ffff:192.0.2.1');assert str(mapped.ipv4_mapped)=='192.0.2.1'
zone_a=IPv6Address('fe80::1%eth0');zone_b=IPv6Address('fe80::1%eth1')
assert zone_a!=zone_b and zone_a.packed==zone_b.packed
print(json.dumps({'variants':variants,'same_128_bits':len(set(parsed))==1,'normalized':str(parsed[0]),
'leftmost_tie':tie,'single_zero_retained':single,'rejected':bad,'url_host':url.hostname,'url_port':url.port,
'unbracketed_url_port_rejected':bad_url,'mapped_python_text':mapped.compressed,'mapped_ipv4':str(mapped.ipv4_mapped),
'zone_scope_ids':[zone_a.scope_id,zone_b.scope_id],'same_bits_different_scopes':True}))
"""
name='xbcatm-socket-'+uuid.uuid4().hex[:10]
try:
result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none',
'--memory','128m','--pids-limit','64',image,'python3','-'],input=program,
capture_output=True,text=True,timeout=60)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True))
finally:
subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)
Three strings represent the same address, including 2001:0DB8:0000:0000:0000:ff00:0042:8329 and 2001:db8::ff00:42:8329. Their string set contains three entries, while their parsed-address set contains one. The normalized output is 2001:db8::ff00:42:8329. This directly exposes why textual spelling is an unreliable key when the application’s identity is meant to be the underlying address.
For 2001:0:0:1:0:0:1:1, the first of two equal zero runs is shortened, producing 2001::1:0:0:1:1. The valid spelling 2001:db8::1:2:3:4:5 contains only one omitted zero group, so its normalized output restores that group as 2001:db8:0:1:2:3:4:5. The exercise explicitly verifies the single-zero rule rather than recommending compression wherever it makes a string shorter.
Inputs with two :: markers, too many groups, or nonhexadecimal digits fail address parsing. A string ending in /64 also fails this address-only constructor because it describes a different kind of input. Select an interface or network parser when a prefix is part of the contract; do not silently discard the suffix merely to make an address parser accept it.
The bracketed URL https://[2001:db8::1]:8443/health separates hostname and port correctly. Accessing the port of the intentionally unbracketed counterpart raises ValueError. The fixture also recognizes the IPv4 component of ::ffff:192.0.2.1; this pinned runtime prints that mapped address in mixed notation. Those results describe the tested parser, not a claim that every runtime formats every special address identically.
Two scoped values, fe80::1%eth0 and fe80::1%eth1, have identical packed address bytes but compare differently with their scopes retained. The zone is additional context rather than part of the 128-bit address. No such interfaces are contacted here. The test checks that the representation can preserve the distinction, not that either scoped destination is configured or reachable.
Choose the comparison contract before storing text
Decide whether a field stores an address, network prefix, interface assignment, URL, or socket endpoint. Use that type’s parser and preserve its required components. Keeping raw input separately can help explain a configuration mistake, while a normalized representation supports consistent display and indexing. Neither normalization nor parsing authenticates who controls the address or whether the requested service is safe.
Document treatment of mapped IPv4 and scope identifiers when correlating logs or applying address rules. Test equivalent valid forms, malformed inputs, and context-bearing cases against that policy. The goal is a stable interpretation of the intended endpoint, not merely the shortest possible string. Address formatting becomes dependable when the binary value and its surrounding connection context remain distinct and explicit.
Sources
- RFC 4291 IPv6 addressing, valid IPv6 syntax, compressed forms and embedded IPv4, checked 2026-09-12.
- RFC 5952 IPv6 text representation, recommended zero compression, tie handling and lowercase, checked 2026-09-12.
- RFC 3986 URI syntax, bracketed IP literals and separation from a port, checked 2026-09-12.
- Python 3.11 ipaddress, parsed address equality, mapped addresses and scope IDs, checked 2026-09-12.