Comparing IP Addresses with == Finally Works in Go
Try comparing two addresses with the old type and the compiler stops you straight away:
a := net.ParseIP("192.0.2.1")
b := net.ParseIP("192.0.2.1")
fmt.Println(a == b) // invalid operation: slice can only be compared to nil
That is because net.IP is defined as []byte. Slices are not comparable, so you reach for a.Equal(b), and then keep reaching for workarounds for everything else: map keys, struct fields, set membership, deduplication.
net/netip, added in Go 1.18, fixes this at the root by making the address a value type. I want to go through what that buys you, and the few places where "just use ==" still bites.
The slice type made equality and map keys awkward
A net.IP can be 4 bytes or 16 bytes, and the same IPv4 address has two legitimate representations: the 4-byte form and the 16-byte IPv4-mapped form (::ffff:192.0.2.1). ParseIP gives you the 16-byte form even for a dotted quad.
So bytes.Equal on two slices that mean the same address can return false, which is why IP.Equal exists. It compares them after normalising.
The knock-on effects are the annoying part:
- You cannot use
net.IPas a map key, so people writem[ip.String()], which allocates on every lookup. - A struct containing a
net.IPis not comparable either, so it cannot be a map key or satisfycomparablein a generic function. - A slice is a header pointing at memory something else might mutate, so a stored
net.IPis not really a value you own.
netip.Addr is a small comparable struct
netip.Addr is a small struct: a 128-bit value plus a tag for the family and zone. Copying it copies the whole address, nothing aliases, and it contains no slice, so it is comparable. Because of that, this all just works:
package main
import (
"fmt"
"net/netip"
)
type Peer struct {
Addr netip.Addr
Port uint16
}
func main() {
seen := map[netip.Addr]int{}
for _, s := range []string{"192.0.2.1", "192.0.2.1", "2001:db8::1"} {
a, err := netip.ParseAddr(s)
if err != nil {
fmt.Println("bad address:", err)
continue
}
seen[a]++
}
fmt.Println(len(seen)) // 2
p1 := Peer{netip.MustParseAddr("192.0.2.1"), 443}
p2 := Peer{netip.MustParseAddr("192.0.2.1"), 443}
fmt.Println(p1 == p2) // true
}
No string conversion, and Peer is a perfectly good map key. For the common "address plus port" case there is also netip.AddrPort, which is comparable too and parses "192.0.2.1:443" and "[2001:db8::1]:443" directly with netip.ParseAddrPort.
Actually, one more thing worth knowing: the zero value netip.Addr{} is valid Go but is not a valid address. IsValid() returns false and it prints as invalid IP.
That gives you a cheap "unset" sentinel without pointers or a separate bool. It also means an unchecked zero can flow a long way before anyone notices.
Where == still surprises you
Comparable does not mean "semantically equal in every sense you might want". Two cases catch people.
IPv4-mapped IPv6 is not the same address
netip keeps the two families distinct. This is deliberate, and it is a change from net.IP.Equal:
v4 := netip.MustParseAddr("192.0.2.1")
mapped := netip.MustParseAddr("::ffff:192.0.2.1")
fmt.Println(v4 == mapped) // false
fmt.Println(v4 == mapped.Unmap()) // true
fmt.Println(mapped.Is4In6()) // true
This matters most on dual-stack listeners. A server bound to [::]:443 can report IPv4 clients as mapped addresses, so a blocklist keyed on plain IPv4 addresses would silently miss them.
The habit to build: call Unmap() on any address arriving from the network before you compare it or store it as a key. It is a no-op for anything that is not 4-in-6, so it is safe to call unconditionally.
Zones are part of the identity
A link-local IPv6 address can carry a zone, such as fe80::1%eth0. That zone is part of the value, so fe80::1%eth0 != fe80::1 and also differs from fe80::1%eth1.
That is correct (the same link-local address on two interfaces really is two different destinations), but if you are doing an allow-list check you may want a.WithZone("") first. Note also that Prefix.Contains returns false for an address with a zone, so strip it before a containment check.
Prefixes: mask before you compare
netip.Prefix is comparable as well, which is lovely, right up to the point where you compare the wrong thing:
a := netip.MustParsePrefix("10.1.2.3/8")
b := netip.MustParsePrefix("10.0.0.0/8")
fmt.Println(a == b) // false: a keeps its host bits
fmt.Println(a.Masked() == b) // true
ParsePrefix keeps the host bits you typed. If the prefixes come from user config, call Masked() when you load them, and then == and map lookups behave the way you expect.
For membership tests, p.Contains(addr) replaces the old net.IPNet.Contains and does not allocate.
Sorting addresses is now a one-liner
Since Go 1.18 there is a.Compare(b) and a.Less(b), so sorting is a one-liner with the slices package:
addrs := []netip.Addr{
netip.MustParseAddr("2001:db8::1"),
netip.MustParseAddr("192.0.2.9"),
netip.MustParseAddr("192.0.2.1"),
}
slices.SortFunc(addrs, netip.Addr.Compare)
The order is:
- IPv4 before IPv6,
- then by numeric value,
- then by zone.
That is a sensible total order, which you never really had with raw byte slices of mixed length.
Converting at the boundary with older APIs
Plenty of the standard library and most third-party code still speaks net.IP. The conversions are short:
// net.IP -> netip.Addr
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return fmt.Errorf("invalid IP length %d", len(ip))
}
addr = addr.Unmap() // a 16-byte v4 comes through as 4-in-6
// netip.Addr -> net.IP
ip := net.IP(addr.AsSlice())
Do the Unmap() there, at the boundary, because AddrFromSlice faithfully preserves the 16-byte form of an IPv4 address.
For connections, *net.TCPAddr has an AddrPort() method, so conn.RemoteAddr().(*net.TCPAddr).AddrPort() gets you a comparable value without formatting and re-parsing a string (check the type assertion in real code, of course).
You give up very little
Not much, honestly. netip.Addr is 24 bytes on 64-bit platforms, against 24 bytes for a slice header plus a separate heap allocation for the backing array.
Hang on, how does something with a zone string stay comparable? The zone is stored as an interned handle, which is why the whole thing stays comparable and cheap to copy.
The remaining cost is migration: anything that takes a net.IP needs a conversion. JSON or database drivers may want MarshalText, which Addr implements, so it round-trips through encoding/json as a string without extra work.
For new code that stores, deduplicates or looks up addresses, I would default to netip and only convert to net.IP at the edges where an older API insists on it. The compile error at the top of this post turns into a working comparison, which is a rare thing to be able to say about a type change.