Go's net/netip vs net.IP: Why == Finally Works on Addresses
Try this 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.
Why the slice was a problem
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.IP as a map key, so people write m[ip.String()], which allocates on every lookup. A struct containing a net.IP is not comparable either, so it cannot be a map key or satisfy comparable in a generic function. And a slice is a header pointing at memory something else might mutate, so a stored net.IP is not really a value you own.
What netip.Addr is
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, but 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.
Ordering and sorting
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)
IPv4 sorts 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.
Bridging to the old world
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).
What you give up
Not much. 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. 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, and 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.