# IPv4 vs IPv6: what developers actually need to know

_2026-09-07 · Boundstone (https://boundstone.io/blog/ipv4-vs-ipv6)_


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:

```text
192.0.2.1
```

An IPv6 address is 128 bits, written as eight groups of four hexadecimal digits (call them hextets), separated by colons:

```text
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:

```text
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:

```python
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:

```text
::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:

```javascript
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](/blog/validate-ip-nodejs), and the classification ranges — private, loopback, bogon — in [what is a private IP address](/blog/what-is-a-private-ip).

## 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:

```bash
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"}'
```

```json
{
  "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](/tools/ip-validator) 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_address` in Python, `net.isIP` in 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_performed` list so you know precisely what a "valid" does and doesn't claim.
