Validate an IP address in Go with the standard library
Go's standard library validates and classifies IP addresses on its own — here's how, and the honest line where an HTTP API earns its place.
Contents
You need to validate an IP address in Go — a value arrived from a form field, an X-Forwarded-For header, or a config file, and you want to know it's a real address before you route on it, log it, or store it. This is one of the rare cases where the standard library hands you everything. net.ParseIP tells you whether a string is a valid address, and a small set of Is* methods tell you what kind of address it is. No third-party package, no network call. Walk through it below, and then we'll draw an honest line around the one thing the stdlib genuinely cannot tell you.
The standard library does the whole job
Two questions usually hide inside "is this a valid IP": is the string a well-formed address, and what class of address is it (public, private, loopback, and so on)? Go answers both in net. For a single Go service that just needs to accept or reject an address and branch on its type, you will not need anything beyond the stdlib. Say it plainly to yourself before you add a dependency.
Parse and validate: net.ParseIP
net.ParseIP returns a net.IP for anything it can parse and nil for anything it can't. The nil check is your validation:
package main
import (
"fmt"
"net"
)
func main() {
for _, s := range []string{"8.8.8.8", "2606:4700:4700::1111", "999.1.1.1", "hello"} {
ip := net.ParseIP(s)
if ip == nil {
fmt.Printf("%-24s invalid\n", s)
continue
}
fmt.Printf("%-24s valid -> %s\n", s, ip.String())
}
}
It accepts both IPv4 and IPv6 in canonical forms and rejects out-of-range octets like 999.1.1.1. It does not accept a port, a CIDR suffix, or leading zeros — if your input might carry those, split them off first (net.SplitHostPort, net.ParseCIDR) before you parse.
IPv4 or IPv6: ip.To4()
Once you have a net.IP, To4() returns a non-nil 4-byte value for an IPv4 address and nil for an IPv6 address:
func version(ip net.IP) int {
if ip.To4() != nil {
return 4
}
return 6
}
One honest caveat: an IPv4-mapped IPv6 address such as ::ffff:1.2.3.4 also returns non-nil from To4(), so it reports as version 4. If that distinction matters to you, the newer net/netip package (below) separates Is4() from Is4In6() cleanly.
Classify: the Is* methods
This is where Go quietly does more than most people expect. A net.IP carries range-classification methods built in:
func classify(ip net.IP) string {
switch {
case ip.IsLoopback():
return "loopback"
case ip.IsLinkLocalUnicast():
return "link-local"
case ip.IsPrivate(): // Go 1.17+
return "private"
case ip.IsMulticast():
return "multicast"
case ip.IsGlobalUnicast():
return "public"
default:
return "other"
}
}
Order matters here, and for a reason worth knowing: Go's IsGlobalUnicast() returns true even for private addresses like 10.0.0.5 or 192.168.1.1 — it reports "is this a global-unicast type address," not "is this routable on the public internet." So you must test IsPrivate() and IsLoopback() before IsGlobalUnicast(), or every private address will fall through to "public." IsPrivate() covers the RFC 1918 IPv4 ranges and IPv6 unique-local (fc00::/7); if you want the full picture of what "private" actually means, see what is a private IP address.
The newer value type: net/netip
For new code, reach for net/netip. Its Addr is a small comparable value (usable as a map key, no heap allocation), and it carries the same classification methods:
package main
import (
"fmt"
"net/netip"
)
func main() {
addr, err := netip.ParseAddr("10.0.0.5")
if err != nil {
fmt.Println("invalid:", err)
return
}
fmt.Println("valid: ", addr.String())
fmt.Println("is4: ", addr.Is4())
fmt.Println("is4In6: ", addr.Is4In6())
fmt.Println("private: ", addr.IsPrivate())
fmt.Println("loopback: ", addr.IsLoopback())
fmt.Println("multicast:", addr.IsMulticast())
}
ParseAddr returns an error instead of a sentinel nil, which reads better in Go and tells you why the parse failed. Same validation, same classification, sharper types.
When an HTTP API earns its place
Here is the honest boundary. If a single Go service is all you have, the code above is the whole answer — ship it and move on. An API earns its place when the same classification has to hold across many services and languages at once: a Node front end, a Python worker, and this Go service should all agree on what "private" or "bogon" means, from one contract, without each re-implementing the rules.
That's what Boundstone's /v1/verify/ip does — the same format check, version, and range classification you just wrote, exposed over HTTP as one system:
curl -s https://api.boundstone.io/v1/verify/ip \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ip":"8.8.8.8"}'
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]string{"ip": "8.8.8.8"})
req, _ := http.NewRequest("POST", "https://api.boundstone.io/v1/verify/ip", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer bs_live_YOUR_KEY")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Valid bool `json:"valid"`
Version int `json:"version"`
Classification string `json:"classification"`
IsPublic bool `json:"is_public"`
IsBogon bool `json:"is_bogon"`
Checks struct {
Performed []string `json:"performed"`
NotPerformed []string `json:"not_performed"`
} `json:"checks"`
}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Printf("%+v\n", out)
}
The response carries the same fields plus a checks object, and that object is the point. For an IP, checks.performed is ["format", "version", "range_classification"] — exactly the work net.ParseIP and the Is* methods do. And checks.not_performed is ["geolocation", "asn", "hosting_datacenter", "proxy_vpn_tor", "reputation"].
Read that second list as a promise, not a disclaimer. Boundstone does not geolocate an IP, look up its ASN, tell you whether it belongs to a hosting provider or datacenter, detect proxies/VPNs/Tor, or score its reputation. That IP-intelligence layer needs licensed data and has not shipped. Because the not_performed list is spelled out on every response, a "classification": "public" from this endpoint means precisely "range classification says public" — never a quietly-implied "and it's a clean residential IP," which the API has no basis to claim.
Want to try the classification before writing any Go? The keyless IP validator tool runs it in the browser, no signup. Working in another language too? The same contract in Python is covered in validate an IP address in Python.
The short version
- Just Go, one service? Use the stdlib.
net.ParseIP(s) == nilvalidates;ip.To4() != nilgives you the version; theIs*methods classify. Prefernet/netip.ParseAddrfor new code — comparable value, real errors. - Watch the ordering.
IsGlobalUnicast()istruefor private addresses too, so testIsPrivate()/IsLoopback()first. - Reach for the API when many services and languages must agree on one classification contract over HTTP — not to get more than the stdlib computes, but to get the same answer everywhere, with
checks.not_performedmaking the boundary explicit. - What no format check gives you — geolocation, proxy/VPN, reputation — Boundstone doesn't fake. It's listed as
not_performed, so a "valid" stays honest.