# Validate an IP address in Go with the standard library

_2026-09-16 · Boundstone (https://boundstone.io/blog/validate-ip-go)_


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:

```go
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:

```go
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:

```go
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](/blog/what-is-a-private-ip).

## 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:

```go
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`](/docs) does — the same format check, version, and range classification you just wrote, exposed over HTTP as one system:

```bash
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"}'
```

```go
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](/tools/ip-validator) 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](/blog/validate-ip-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.
