IPv4 vs IPv6: what developers actually need to know
The practical differences you actually hit in code — notation, address space, :: compression, embedded IPv4 — and why every field that takes an IP now has to accept both.
Contents
If you have ever written a regex for an IP address and watched it fall over the first time someone typed ::1, you already understand the practical stakes of IPv4 vs IPv6. This is the developer's version of the comparison — not a history lecture, but the handful of concrete differences that show up in your parsing, your storage, and your validation: two very different notations, a wildly larger address space, the :: shorthand that trips naive code, IPv4 addresses hiding inside IPv6 ones, and the fact that any input field taking an IP today has to accept both.
Two notations for the same job
An IPv4 address is 32 bits, written as four decimal octets from 0 to 255, separated by dots — the familiar dotted-quad:
192.0.2.1
An IPv6 address is 128 bits, written as eight groups of four hexadecimal digits (call them hextets), separated by colons:
2001:0db8:0000:0000:0000:ff00:0042:8329
Same job — identify a host on a network — but almost nothing about the string is portable between them. IPv4 is decimal and dot-separated; IPv6 is hexadecimal, colon-separated, and case-insensitive (canonical form is lowercase). Code that assumes "an IP has three dots in it" is already wrong for half the internet.
Why IPv6 exists: the address space
The reason for the second format is arithmetic. IPv4's 32 bits give you 2^32 addresses — about 4.3 billion. That sounded infinite in 1981 and ran out in practice years ago; the workarounds (NAT, carrier-grade NAT) are why your phone and your laptop can share one public address.
IPv6's 128 bits give you 2^128 addresses. The number is large enough that it stops being intuitive, so the useful takeaway is simply this: the space is why the format changed, and why the format changed is why your code has to handle both for the foreseeable future. (We are not going to quote you an adoption percentage — pick your own number from your own traffic logs, not a blog post.)
:: and leading zeros: the compression rules
IPv6 addresses are long, so the spec defines two shortenings, and both matter when you compare or store addresses.
First, drop leading zeros within each hextet: 0db8 becomes db8, 0000 becomes 0.
Second, replace one run of consecutive all-zero hextets with a double colon ::. You may do this only once per address — two :: would be ambiguous, because a parser couldn't tell how many zero groups each one stood for.
Apply both rules and the address above collapses:
2001:0db8:0000:0000:0000:ff00:0042:8329
2001:db8::ff00:42:8329
Both strings are the same address. So is ::1 (loopback, all zeros but the last bit) and :: (the unspecified address, all zeros). The consequence for your code is direct: string equality is not address equality. 2001:db8::1 and 2001:0db8:0000:0000:0000:0000:0000:0001 are identical hosts and different strings. To compare, you normalize first, then compare — which is exactly what a real parser gives you:
import ipaddress
a = ipaddress.ip_address("2001:db8::1")
b = ipaddress.ip_address("2001:0db8:0000:0000:0000:0000:0000:0001")
a == b # True — compared as integers, not strings
a.compressed # '2001:db8::1' (canonical short form)
a.exploded # '2001:0db8:0000:0000:0000:0000:0000:0001'
Embedded IPv4, and the parsers that trip on it
The two worlds also overlap on purpose. An IPv4-mapped IPv6 address embeds a dotted-quad inside IPv6 notation, which is how dual-stack sockets represent an IPv4 peer:
::ffff:192.0.2.1
There are others — NAT64 uses the 64:ff9b::/96 prefix to carry IPv4 addresses across an IPv6-only network — but the point for a developer is the same. A string with both colons and dots is a valid, common address, and a hand-rolled splitter that keys on one separator or the other will mangle it. This is the single strongest argument for never validating IPs with a regex: the format has legal forms your pattern's author never imagined. Reach for the parser your language already ships.
Dual-stack: accept both, everywhere
Because both protocols are live, every place you accept an IP — a signup form, an X-Forwarded-For header, an allowlist — receives both. Node's standard library makes the accept-both check a one-liner, and it returns which family it found:
const net = require("node:net");
net.isIP("192.0.2.1"); // 4
net.isIP("2001:db8::ff00:42:8329"); // 6
net.isIP("::ffff:192.0.2.1"); // 6 (embedded IPv4 is still IPv6)
net.isIP("not-an-ip"); // 0 (falsy = invalid)
For most fields, that is genuinely the whole job, and if the standard library answers your question you should not pay anyone for more. We cover the language-specific path in validating an IP address in Node.js, and the classification ranges — private, loopback, bogon — in what is a private IP address.
Where an API is the layer past the stdlib
The parser tells you a string is a valid address and which version it is. Boundstone does that same work over HTTP, as part of one system, and returns a small classification alongside it — so a service in Go, a worker in PHP, and a webhook in Ruby all get the identical contract instead of three subtly different local answers:
curl -s https://api.boundstone.io/v1/verify/ip \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ip":"2606:4700:4700::1111"}'
{
"valid": true,
"version": 6,
"normalized": "2606:4700:4700::1111",
"classification": "public",
"is_public": true,
"is_bogon": false,
"checks": {
"performed": ["format", "version", "range_classification"],
"not_performed": ["geolocation", "asn", "hosting_datacenter", "proxy_vpn_tor", "reputation"]
}
}
Note it accepts and classifies both families — hand it a dotted-quad or a colon-separated hextet string like the one above, and version comes back 4 or 6 with normalized in canonical form, so you get the equality-safe string for free.
Read the not_performed list, because it is the honest part of the answer. Boundstone does not geolocate the address, look up its ASN, detect whether it belongs to a hosting or datacenter provider, flag proxy/VPN/Tor, or score its reputation. That IP intelligence needs licensed data and is not shipped — so a valid: true here means exactly "this is a well-formed, classified address," and nothing you didn't ask for. That is the whole point: the list of what we skipped is why you can trust the field we returned. If you just want to paste one in and see it, the keyless IP validator tool runs the same check with no signup.
The short version
- Notation: IPv4 is four decimal octets with dots; IPv6 is eight hex hextets with colons. Don't assume one shape.
::compresses one run of zero hextets, once. Normalize before you compare — equal addresses are not equal strings.- Embedded IPv4 (
::ffff:192.0.2.1) is real and common, and stays IPv6. Use a parser, not a regex. - Accept both:
ipaddress.ip_addressin Python,net.isIPin Node do the format-and-version work correctly and for free. - Reach for an API when you want one identical contract across languages plus public/private/bogon classification in a single call — and read the
not_performedlist so you know precisely what a "valid" does and doesn't claim.