Articles / Go

  • go
  • netip
  • net-ip
  • networking
  • maps
  • ipv6

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.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.
  • A slice is a header pointing at memory something else might mutate, so a stored net.IP is 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:

  1. IPv4 before IPv6,
  2. then by numeric value,
  3. 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.