📇 Analogy
DNS is the phone book of the internet. You know a name — example.com — but the network only routes to numbers — 93.184.216.34. A resolver looks the name up and hands back the number. Parsing and splitting addresses, by contrast, is just reading the phone book entry you already have — no call required.
Addresses: parsing vs. resolving
Two very different things get lumped together as “addressing.” Parsing an IP or a host:port string is pure, local, and instant. Resolving a hostname means a trip to a DNS server over the network — slow, fallible, and worth a timeout.
graph LR N["name<br/>example.com:443"] -->|"SplitHostPort"| H["host: example.com<br/>port: 443"] H -->|"Resolver (DNS, network)"| IP["93.184.216.34<br/>2606:2800:..."] L["literal<br/>10.0.0.1"] -->|"ParseIP / ParseAddr (local)"| A["net.IP / netip.Addr"]
Modern Go has two IP types. The classic net.IP is a []byte slice — it allocates and can’t be a map key. The newer net/netip gives you netip.Addr and netip.AddrPort: small, comparable value types you can use with == and as map keys. Prefer net/netip for new code.
Inspecting addresses, in-process
All of this is pure string parsing — no DNS, no socket — so it runs anywhere and is fully deterministic:
package main
import (
"fmt"
"net"
"net/netip"
)
func main() {
// 1) net.ParseIP — the classic []byte-backed net.IP type
v4 := net.ParseIP("192.168.0.42")
v6 := net.ParseIP("2001:db8::1")
fmt.Println("v4:", v4, "is4:", v4.To4() != nil)
fmt.Println("v6:", v6, "is4:", v6.To4() != nil)
fmt.Println("loopback?", net.ParseIP("127.0.0.1").IsLoopback())
fmt.Println("private?", v4.IsPrivate())
// 2) net.SplitHostPort — break "host:port" into its parts (DNS-free)
host, port, _ := net.SplitHostPort("example.com:8443")
fmt.Println("host:", host, "port:", port)
// JoinHostPort wraps an IPv6 literal in [] for you
fmt.Println("joined:", net.JoinHostPort("2001:db8::1", "443"))
// 3) net/netip — the modern, comparable, allocation-free address type
ap, _ := netip.ParseAddrPort("[2001:db8::1]:443")
fmt.Println("addr:", ap.Addr(), "port:", ap.Port(), "is6:", ap.Addr().Is6())
a, _ := netip.ParseAddr("10.0.0.1")
fmt.Println("netip addr:", a, "is4:", a.Is4(), "private:", a.IsPrivate())
}
A few things to notice: SplitHostPort strips the IPv6 brackets, JoinHostPort puts them back, and ports are always strings in these APIs ("443"), because a host:port literal is text — convert with strconv.Atoi only when you need the number.
Subnets and CIDR prefixes
A CIDR prefix like 10.0.0.0/8 describes a range of addresses — essential for allow-lists, firewall rules, and classifying traffic. net/netip models it as a netip.Prefix, with Contains for membership and Masked to normalize to the network address. Pure parsing, so it runs anywhere:
package main
import (
"fmt"
"net/netip"
)
func main() {
prefix := netip.MustParsePrefix("10.0.0.0/8")
for _, s := range []string{"10.1.2.3", "192.168.0.1", "10.255.255.255"} {
ip := netip.MustParseAddr(s)
fmt.Printf("%-15s in %s? %v\n", s, prefix, prefix.Contains(ip))
}
// Masked() zeroes the host bits → the canonical network address.
p := netip.MustParsePrefix("192.168.5.130/24")
fmt.Println("network:", p.Masked()) // 192.168.5.0/24
// The classic []byte API does the same: net.ParseCIDR + (*IPNet).Contains.
}
Resolving names with DNS
When you actually need to turn a name into IPs, you hit the network. These calls reach a DNS server, so they can be slow or fail — pass a context.Context with a deadline. (Shown as fenced examples because they touch the network and won’t run in a sandbox.)
// quick one-shots
ips, err := net.LookupHost("example.com") // []string of IPs
addrs, err := net.LookupIP("example.com") // []net.IP
cname, err := net.LookupCNAME("www.example.com")
// the configurable way: a *net.Resolver with a context deadline
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var r net.Resolver
hosts, err := r.LookupHost(ctx, "example.com")
mxs, err := r.LookupMX(ctx, "example.com") // mail servers
_ = err
Most of the time you never call these directly — net.Dial("tcp", "example.com:443") and the HTTP client resolve the name for you under the hood, trying each returned address in turn.
Reference
| Task | Call |
|---|---|
| Parse an IP literal | netip.ParseAddr (or net.ParseIP) |
Parse host:port | netip.ParseAddrPort (or net.SplitHostPort) |
Build host:port | net.JoinHostPort(host, port) |
| Parse a subnet | netip.ParsePrefix("10.0.0.0/8") |
| Subnet membership | prefix.Contains(addr) |
| Is it private/loopback? | addr.IsPrivate() / addr.IsLoopback() |
| Resolve a name → IPs | net.LookupIP / (*net.Resolver).LookupHost(ctx, …) |
| MX / CNAME / TXT records | net.LookupMX / LookupCNAME / LookupTXT |
⚠️ Parsing is local; resolving is a network call
The playgrounds are pure parsing (no DNS, no socket) so they’re safe and deterministic anywhere. The trap is forgetting that LookupHost/Resolver methods are network calls: they can block, time out, or return multiple addresses (IPv4 and IPv6). Always pass a context with a timeout, expect more than one IP back, and don’t cache results past their TTL — DNS records change. For new code, prefer net/netip types so addresses stay comparable and allocation-free.
See also
- TCP sockets and UDP sockets — what you do with a resolved address.
- HTTP client — resolves names for you; reuses connections per host.
- time —
context.WithTimeoutfor bounding a lookup.
Next: turning a byte stream into messages — framing & custom protocols.
Related topics
The reliable byte stream — net.Dial and net.Listen, the Conn interface every connection implements (an io.Reader+Writer), framing a byte stream, read/write deadlines and net.Error, and one goroutine per connection.
net-basicsUDP SocketsConnectionless datagrams in Go — net.ListenPacket and ReadFrom/WriteTo, DialUDP, message boundaries with no delivery guarantees, encoding a datagram payload, MTU and fragmentation, and when UDP beats TCP.
httpHTTP ClientCalling services with net/http — http.Get vs a configured http.Client with timeouts, building requests and posting JSON, checking status, closing and draining the body, connection reuse via Transport, and context cancellation.
Check your understanding
Score: 0 / 51. Why prefer net/netip's Addr over the older net.IP for new code?
net/netip.Addr is a compact value type: comparable with ==, usable as a map key, and allocation-free. net.IP is a []byte slice, so it allocates and can't be compared with == or used as a key. net.IP still works everywhere, but netip is the modern default.
2. What does net.SplitHostPort("[2001:db8::1]:443") return?
SplitHostPort understands IPv6 literals and removes the surrounding brackets, returning the bare host and the port as strings. Its inverse, JoinHostPort, adds the brackets back around an IPv6 literal.
3. What does a DNS lookup like net.LookupHost("example.com") actually do?
LookupHost (and the methods on net.Resolver) send a query to a DNS server and return the resolved IPs. It hits the network and can be slow or fail — unlike net.ParseIP/SplitHostPort, which are pure local string parsing.
4. How do you test whether an IP falls inside a subnet like 10.0.0.0/8?
netip.ParsePrefix("10.0.0.0/8") yields a netip.Prefix; Contains(addr) reports membership. (The older net.ParseCIDR + IPNet.Contains does the same with []byte types.) Useful for allow-lists and classifying private vs public addresses.
5. A name resolves to several IPs (IPv4 and IPv6). What should your code assume?
Hosts commonly have both A (IPv4) and AAAA (IPv6) records, sometimes several each. net.Dial walks the list until one connects (Happy Eyeballs). Treat resolution as returning a set, give it a timeout, and don't cache past the TTL.
Comments
Sign in with GitHub to join the discussion.