The string is just a label
You are building a proxy that routes traffic based on source addresses. The configuration file lists allowed IPs as strings like "10.0.0.5" and "2001:db8::1". Your code needs to compare incoming connections against this list, check if an address is private, or pass the address to a dialer. Strings are useless for logic. You cannot compare "10.0.0.5" with "10.0.0.005" reliably, and you certainly cannot ask a string if it is loopback.
Go represents IP addresses with the net.IP type. Under the hood, net.IP is just a slice of bytes. IPv4 addresses occupy four bytes. IPv6 addresses occupy sixteen bytes. Go unifies these into a single type so you can write code that handles both without branching everywhere. This unification brings power and a few quirks around how the bytes are stored and compared.
Parsing returns nil, not error
The net package follows a pattern for simple lookups: return the value or nil. net.ParseIP does not return an error. It returns a net.IP value, or nil if the string is garbage. This keeps the happy path clean. You check for nil to handle the failure.
Here is the minimal pattern for parsing and formatting. The code checks the nil return, prints the result, and builds an IP from raw octets using the constructor.
package main
import (
"fmt"
"net"
)
func main() {
// ParseIP returns nil on failure. It does not return an error value.
ip := net.ParseIP("192.168.1.1")
if ip == nil {
fmt.Println("invalid IP")
return
}
// String() formats the IP back to a human-readable representation.
fmt.Println(ip.String())
// IPv4 constructs an IP from four integer octets.
// It returns a net.IP of length 4.
loopback := net.IPv4(127, 0, 0, 1)
fmt.Println(loopback.String())
}
ParseIP tries to parse the input as IPv4 first, then falls back to IPv6. If neither works, you get nil. The net.IPv4 constructor builds an IPv4 address directly from four bytes. It is faster than parsing a string when you already have the numbers.
ParseIP returns nil. Check nil, not error.
Building from integers and bytes
Network code often deals with raw integers. A database might store an IPv4 address as a uint32. A protocol buffer might send four bytes. You need to turn these into net.IP values to use the standard library methods.
net.IP is a []byte. You can create one by making a slice and filling it. Network byte order is big-endian, meaning the most significant byte comes first. Use encoding/binary to handle the conversion correctly.
package main
import (
"encoding/binary"
"fmt"
"net"
)
// IPFromUint32 converts a 32-bit integer to a net.IP.
// The integer must be in host byte order; the function handles endianness.
func IPFromUint32(n uint32) net.IP {
// Allocate a 4-byte slice for IPv4.
ip := make(net.IP, 4)
// Encode the integer into the slice using big-endian network order.
binary.BigEndian.PutUint32(ip, n)
return ip
}
func main() {
// 10.0.0.1 as a uint32 is 167772161.
ip := IPFromUint32(167772161)
fmt.Println(ip.String())
}
The make(net.IP, 4) call creates a slice of length 4. binary.BigEndian.PutUint32 writes the integer into those bytes in the correct order. The result is a valid net.IP that behaves like any other IP address. You can use net.IPv6 to construct IPv6 addresses from sixteen bytes, though that is less common in application code.
Use constructors for raw bytes. Let binary handle endianness.
The IPv4 mapping reality
Go stores IPv4 addresses in a way that is compatible with IPv6. The internal representation can be a 4-byte slice or a 16-byte slice where the IPv4 address is embedded in the last four bytes. This is called IPv4-mapped IPv6.
When you call ParseIP("192.168.1.1"), the result is usually a 4-byte slice. When you call To16(), Go pads it to 16 bytes with the mapping prefix. When you call To4(), Go returns a 4-byte slice if the IP is IPv4 or IPv4-mapped, or nil if it is a pure IPv6 address.
This mapping causes confusion when comparing lengths or printing. String() handles the mapping automatically and prints the IPv4 form even if the internal slice is 16 bytes. You rarely need to worry about the mapping unless you are doing byte-level manipulation or storing IPs in a fixed-size buffer.
Use To4() to check if an IP is IPv4. Use To16() to normalize to 16 bytes.
package main
import (
"fmt"
"net"
)
func main() {
// ParseIP returns a 4-byte slice for IPv4 input.
ip4 := net.ParseIP("10.0.0.1")
fmt.Println(len(ip4)) // 4
// To16 pads the IP to 16 bytes using the IPv4-mapped format.
ip16 := ip4.To16()
fmt.Println(len(ip16)) // 16
// To4 returns nil for pure IPv6 addresses.
ip6 := net.ParseIP("2001:db8::1")
if ip6.To4() == nil {
fmt.Println("this is IPv6")
}
}
The To4() method is the standard way to test for IPv4. It returns nil for pure IPv6 addresses. It returns a non-nil value for both 4-byte IPv4 and 16-byte mapped IPv4. This makes it safe to use regardless of how the IP was constructed.
To4 unwraps. To16 pads. String hides the details.
Comparison requires Equal
net.IP is a slice. Slices in Go are reference types that hold a pointer to an underlying array. You cannot compare slices with ==. The compiler rejects the operation because slice equality requires checking length and element-by-element content, which the == operator does not do for slices.
If you try to compare two IPs with ==, the compiler stops you with invalid operation: ip1 == ip2 (slice can only be compared to nil). You must use the Equal method provided by net.IP. Equal handles the comparison correctly, including the IPv4 mapping cases. It considers a 4-byte IPv4 and its 16-byte mapped equivalent to be equal.
package main
import (
"fmt"
"net"
)
func main() {
a := net.ParseIP("10.0.0.1")
b := net.ParseIP("10.0.0.1")
c := net.ParseIP("10.0.0.2")
// Equal handles length differences and IPv4 mapping.
fmt.Println(a.Equal(b)) // true
fmt.Println(a.Equal(c)) // false
// Comparing to nil is allowed and checks if the IP is zero.
fmt.Println(a.Equal(nil)) // false
}
The Equal method is the only safe way to compare IPs. It abstracts away the slice details and the mapping quirks. Always use Equal. Never use == for IP comparison.
Slices are not comparable. Use Equal.
Using IP in maps and sets
Because net.IP is a slice, it cannot be used as a map key. Map keys in Go must be comparable types. Slices are not comparable. If you try to declare map[net.IP]string, the compiler rejects it with invalid map key type net.IP (slice is not comparable).
To use an IP as a map key, convert it to a comparable type. The most common approach is to use the string representation. ip.String() produces a canonical string that is safe for map keys. Alternatively, you can use ip.To16() and convert the slice to a string, but String() is more readable and handles IPv4 mapping consistently.
package main
import (
"fmt"
"net"
)
func main() {
// Use string representation as the map key.
whitelist := map[string]bool{
"10.0.0.1": true,
"10.0.0.2": true,
}
ip := net.ParseIP("10.0.0.1")
// String() provides a stable key for lookup.
if whitelist[ip.String()] {
fmt.Println("allowed")
}
}
The String() method returns the shortest valid representation. IPv4 addresses print as IPv4. IPv6 addresses print as IPv6. This ensures that the map key matches the input format you expect. If you need to store metadata per IP, a map keyed by string is the standard workaround.
Use String for map keys. Slices cannot be keys.
When to use what
Use net.ParseIP when you have a string from user input, configuration, or network headers and need to convert it to a net.IP value.
Use net.IPv4 when you are constructing an IPv4 address from four integer octets and want a 4-byte result.
Use net.IPv6 when you are constructing an IPv6 address from sixteen integer bytes.
Use IPFromUint32 with encoding/binary when you need to convert a 32-bit integer stored in host order to a net.IP.
Use ip.To4() when you need to check if an IP is IPv4 or force a 4-byte representation.
Use ip.To16() when you need a fixed 16-byte representation for storage or comparison.
Use ip.Equal() when comparing two net.IP values for equality.
Use ip.String() when you need a human-readable representation or a map key.
Use net.IP as []byte when you need to access individual octets, but always check the length first.
Use ip.IsPrivate(), ip.IsLoopback(), and ip.IsGlobalUnicast() when you need to classify the address type.
ParseIP returns nil. Equal compares. String keys maps.