← Blog

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) == nil validates; ip.To4() != nil gives you the version; the Is* methods classify. Prefer net/netip.ParseAddr for new code — comparable value, real errors.
  • Watch the ordering. IsGlobalUnicast() is true for private addresses too, so test IsPrivate() / 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_performed making 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.

Frequently asked questions

How do you validate an IP address in Go using the standard library?

Go's standard library gives you two dependency-free options. net.ParseIP(s) returns nil when the string is not a well-formed IPv4 or IPv6 address, and the newer net/netip package's netip.ParseAddr(s) returns an error you can check instead. Both confirm the format is valid, meaning the string is a syntactically correct address; neither checks that anything is reachable or answering at that address.

How can I tell if a parsed IP is IPv4 or IPv6, or whether it is private or reserved, in Go?

After parsing, a netip.Addr exposes Is4() and Is6() to distinguish the version, plus classifiers like IsPrivate(), IsLoopback(), IsLinkLocalUnicast(), and IsMulticast(); net.IP offers the same family of methods. These let you separate a public, routable address from a private range like 10.x or 192.168.x or a reserved one. That distinction is often what you actually need when deciding whether an incoming IP could be a genuine external client.

Does validating an IP address tell you the user's location or whether they are using a VPN?

No. Validating an IP address with the standard library only confirms its format, version, and range classification. It does not reveal geographic location, the owning network or ASN, whether the address belongs to a datacenter or hosting provider, or whether it is a proxy, VPN, or Tor exit; those all require separately licensed IP-intelligence datasets. Boundstone's IP endpoint is deliberately explicit about this: it returns valid, version, classification, is_public, and is_bogon, and lists geolocation, asn, hosting_datacenter, proxy_vpn_tor, and reputation as not performed rather than guessing.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist