IPv6 for Developers: What Changes and What You Need to Know
IPv6 has been “the future of the internet” for over two decades, and it’s easy to treat it as a niche concern since most development happens fine on IPv4 without ever noticing. But IPv6 traffic now makes up a substantial and growing share of real internet traffic, mobile carriers default to it, and code that silently assumes “an IP address” means “four dotted decimal octets” is code that will eventually break for some fraction of real users. This post covers what actually changes for a developer — address notation, the disappearance of NAT as a default, and the specific places application code tends to break.
Why IPv6 Exists
IPv4 has roughly 4.3 billion addresses, and that space was formally exhausted years ago — the aforementioned NAT is the reason IPv4 has survived this long despite that, letting billions of devices share a shrinking pool of public addresses. IPv6 sidesteps the scarcity problem entirely by using 128-bit addresses instead of IPv4’s 32-bit addresses — not double the space, but astronomically more: 2^128 versus 2^32, a difference so large that “give every device its own globally routable address, no NAT required” becomes practical again.
Address Notation
An IPv6 address is written as eight groups of four hexadecimal digits, separated by colons:
2001:0db8:85a3:0000:0000:8a2e:0370:7334
Two shorthand rules make these more manageable in practice:
Leading zeros in each group can be dropped:
2001:db8:85a3:0:0:8a2e:370:7334
One run of consecutive all-zero groups can be collapsed to ::, but only once per address (since using it twice would make the address ambiguous — there’d be no way to tell how many zero groups belong on each side):
2001:db8:85a3::8a2e:370:7334
$ ping6 -c 1 2001:db8:85a3::8a2e:370:7334
PING 2001:db8:85a3::8a2e:370:7334(2001:db8:85a3::8a2e:370:7334) 56 data bytes
A few addresses worth recognizing on sight: ::1 is the IPv6 loopback (equivalent to IPv4’s 127.0.0.1), :: (all zeros) represents “any address,” and fe80::/10 is the link-local prefix — an address automatically assigned to every IPv6-enabled interface for communication with directly connected neighbors, without needing any DHCP-equivalent configuration at all.
URLs and IPv6: The Bracket Requirement
Because IPv6 addresses already use colons, and URLs use a colon to separate host from port, an IPv6 address in a URL needs to be wrapped in brackets to avoid ambiguity:
http://[2001:db8::1]:8080/api/status
Without the brackets, http://2001:db8::1:8080/ would be genuinely ambiguous to parse — is 8080 a port, or part of the address? This bracket requirement is a common, specific place where code that constructs URLs by simple string concatenation (f"http://{host}:{port}") silently breaks for IPv6 hosts, since it produces a malformed URL the moment host happens to be an IPv6 address rather than a hostname or IPv4 address.
# Breaks for IPv6 addresses
url = f"http://{host}:{port}/api"
# Handles both correctly
from urllib.parse import urlunparse
import ipaddress
def build_host(host):
try:
if ipaddress.ip_address(host).version == 6:
return f"[{host}]"
except ValueError:
pass # not a raw IP, just a hostname
return host
url = f"http://{build_host(host)}:{port}/api"
NAT Mostly Disappears
Because IPv6’s address space is large enough that every device can genuinely have its own globally unique address, the scarcity problem that made NAT necessary for IPv4 simply doesn’t apply — a typical IPv6 deployment gives every device on a network its own routable address directly, no translation layer required.
This is a real architectural shift, not just a cosmetic one: it means the “devices behind NAT can’t receive unsolicited inbound connections” property IPv4 users often rely on for security (even if accidentally) doesn’t hold the same way for IPv6 — a device with a truly public, globally routable address is, in principle, directly reachable from the internet unless something else (a firewall, explicitly) blocks it. Practically, this means firewalling becomes the deliberate, primary security mechanism for IPv6 networks, rather than something NAT provided as an accidental side effect — a distinction worth being explicit about rather than assuming IPv6 devices get the same implicit protection IPv4-behind-NAT devices historically had.
Dual-Stack: The Real-World Transition State
Almost no network today is IPv6-only — the practical reality for the foreseeable future is dual-stack, where a host has both an IPv4 and an IPv6 address simultaneously, and connections can be established over either.
$ ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP>
inet 10.0.1.20/24 brd 10.0.1.255 scope global eth0
inet6 2001:db8::20/64 scope global
inet6 fe80::1234:5678:9abc:def0/64 scope link
When a client needs to connect to a dual-stack server, it has to decide which protocol to try — and modern operating systems use Happy Eyeballs (RFC 8305) to handle this well: attempt both IPv4 and IPv6 connections nearly simultaneously (with IPv6 given a small head start, since it’s generally preferred when available), and use whichever one succeeds first, abandoning the other. This avoids the bad outcome of naively trying IPv6 first, waiting for a full timeout on a broken IPv6 path, and only then falling back to IPv4 — a failure mode that made early “IPv6-enabled” deployments noticeably slower for some users before Happy Eyeballs became standard client behavior.
$ curl -v --happy-eyeballs-timeout-ms 200 https://example.com
* Trying [2606:4700::1]:443...
* Trying 93.184.216.34:443...
* Connected to example.com (2606:4700::1) port 443
What Actually Breaks in Application Code
A short, concrete list of the places IPv4-only assumptions tend to hide:
- IP address validation regexes written to match only the dotted-decimal IPv4 format reject every valid IPv6 address outright — use a proper library (
ipaddressin Python, similar in other languages) rather than a hand-rolled regex. - Fixed-width storage for IP addresses — a database column sized for IPv4’s 15-character maximum string length, or a 4-byte binary field, simply can’t hold an IPv6 address without a schema change.
- Rate limiting and access control keyed by IP need to account for the fact that a single IPv6 customer is often assigned an entire
/64block (not just one address) — rate-limiting by exact address alone can be trivially evaded by an attacker cycling through addresses within their own assigned block, which is why IPv6-aware rate limiting often keys on the/64prefix rather than the full address. - Log parsing and analytics that assume every logged IP fits IPv4’s format will silently mis-parse or drop IPv6 entries if not explicitly tested against them.
- CIDR/subnet math libraries used elsewhere in a codebase (see [[ip subnetting cidr]]) need IPv6-aware equivalents — the bit-counting logic is conceptually the same, but every fixed-width assumption baked in for 32-bit addresses needs updating for 128-bit ones.
import ipaddress
def is_valid_ip(s):
try:
ipaddress.ip_address(s) # accepts both IPv4 and IPv6
return True
except ValueError:
return False
is_valid_ip("2001:db8::1") # True
is_valid_ip("192.168.1.1") # True
is_valid_ip("not-an-ip") # False
Testing IPv6 Support Locally
The most common reason IPv6 bugs ship unnoticed is simply never testing against an actual IPv6 connection during development — everything looks fine on an IPv4-only development network.
$ curl -6 https://example.com
$ curl -4 https://example.com
Forcing a client to use one protocol or the other (-6/-4 flags, widely supported across common tools) is a quick way to explicitly exercise both code paths during development and catch a broken assumption before it reaches production traffic from a real dual-stack or IPv6-only client.
Conclusion
IPv6’s headline change — a vastly larger address space — has a real downstream effect on application code: NAT stops being a given, which shifts security responsibility explicitly onto firewalling rather than an accidental byproduct of address scarcity, and every place code assumed “an IP is four dotted-decimal octets” becomes a latent bug for the growing share of traffic that’s genuinely IPv6. None of the individual fixes are exotic — proper address-parsing libraries instead of hand-rolled regexes, bracket-aware URL construction, /64-aware rate limiting — but they only get caught if IPv6 traffic is actually exercised during development and testing, rather than assumed to behave identically to IPv4 by default.