The IP API The IP API
// Guide

IP Geolocation in Go: A Practical Guide

Jul 27th, 2026 // 7 min read
IP Geolocation in Go: A Practical Guide

Golang IP geolocation comes down to one HTTP GET and one json.Unmarshal - no SDK required. This guide builds a small, typed client for The IP API using only the standard library, then wires it into net/http middleware that stamps every incoming request with the visitor's country code. Along the way we'll handle the failure modes that trip people up in production: context timeouts, non-200 responses, and the case where the API returns HTTP 200 but the lookup itself failed.

A small aside that makes this a natural pairing: The IP API itself is written in Go. The same net/http machinery you'll use below is what serves the API on the other end.

You'll need an API key. The free plan is 1,000 requests per day with no card - sign up here, or read the getting started guide for a tour of the response data first.

The Response Shape as Go Structs

A lookup for 8.8.8.8 returns JSON like this (trimmed):

{
  "status": "OK",
  "body": {
    "ip": "8.8.8.8",
    "location": {
      "city": "Mountain View",
      "country": "United States of America",
      "country_code": "US",
      "latitude": 37.386,
      "longitude": -122.0838,
      "region": "California",
      "timezone": "America/Los_Angeles"
    },
    "asn": {
      "asn": 15169,
      "asn_description": "Google LLC",
      "network": "8.8.8.0/24",
      "org_name": "Google LLC",
      "rir": "ARIN"
    },
    "is_bogon": false,
    "is_datacenter": true,
    "is_vpn": false
  },
  "response_time_ms": 10
}

The structs mirror it field for field. Location, ASN, and Company are pointers because they can be absent: a bogon IP (private ranges per RFC 1918, loopback, other reserved space) returns a minimal body with only ip and is_bogon, and decoding that into value structs would silently give you zero values instead of a nil you can check.

A Golang IP Geolocation Client with net/http

Here is a complete, runnable program. Save it as main.go, replace the key, and go run main.go:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"time"
)

const baseURL = "https://api.theipapi.com"

type Location struct {
	City        string  `json:"city"`
	Country     string  `json:"country"`
	CountryCode string  `json:"country_code"`
	Latitude    float64 `json:"latitude"`
	Longitude   float64 `json:"longitude"`
	Region      string  `json:"region"`
	Timezone    string  `json:"timezone"`
}

type ASN struct {
	ASN            int    `json:"asn"`
	ASNDescription string `json:"asn_description"`
	Country        string `json:"country"`
	Created        string `json:"created"`
	Network        string `json:"network"`
	OrgName        string `json:"org_name"`
	RIR            string `json:"rir"`
	Updated        string `json:"updated"`
}

type Company struct {
	Name    string `json:"name"`
	Address string `json:"address"`
	Network string `json:"network"`
	Route   string `json:"route"`
}

type IPInfo struct {
	IP           string    `json:"ip"`
	Location     *Location `json:"location"`
	ASN          *ASN      `json:"asn"`
	Company      *Company  `json:"company"`
	IsBogon      bool      `json:"is_bogon"`
	IsDatacenter bool      `json:"is_datacenter"`
	IsVPN        bool      `json:"is_vpn"`
}

type LookupResponse struct {
	Status         string  `json:"status"`
	Body           *IPInfo `json:"body"`
	ResponseTimeMs float64 `json:"response_time_ms"`
}

type Client struct {
	apiKey     string
	httpClient *http.Client
}

func NewClient(apiKey string) *Client {
	return &Client{
		apiKey:     apiKey,
		httpClient: &http.Client{Timeout: 10 * time.Second},
	}
}

func (c *Client) Lookup(ctx context.Context, ip string) (*IPInfo, error) {
	endpoint := fmt.Sprintf("%s/v1/ip/%s?api_key=%s",
		baseURL, url.PathEscape(ip), url.QueryEscape(c.apiKey))

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("ipapi: HTTP %d for %s", resp.StatusCode, ip)
	}

	var lr LookupResponse
	if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
		return nil, fmt.Errorf("ipapi: decoding response: %w", err)
	}
	if lr.Status != "OK" || lr.Body == nil {
		return nil, fmt.Errorf("ipapi: lookup failed for %s (status %q)", ip, lr.Status)
	}
	return lr.Body, nil
}

func main() {
	client := NewClient("YOUR_API_KEY")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	info, err := client.Lookup(ctx, "8.8.8.8")
	if err != nil {
		log.Fatal(err)
	}

	if info.Location != nil {
		fmt.Printf("%s -> %s, %s (%s)\n",
			info.IP, info.Location.City, info.Location.Country, info.Location.CountryCode)
	}
	if info.ASN != nil {
		fmt.Printf("AS%d %s via %s\n", info.ASN.ASN, info.ASN.OrgName, info.ASN.RIR)
	}
	fmt.Printf("datacenter=%v vpn=%v bogon=%v\n",
		info.IsDatacenter, info.IsVPN, info.IsBogon)
}

Output:

8.8.8.8 -> Mountain View, United States of America (US)
AS15169 Google LLC via ARIN
datacenter=true vpn=false bogon=false

A few deliberate choices worth calling out:

  • Two layers of timeout. The http.Client has a 10-second ceiling as a safety net, and each call takes a context.Context so the caller sets the real deadline. http.NewRequestWithContext means a cancelled context aborts the request mid-flight instead of leaking a goroutine waiting on a dead connection.
  • The 200-with-Error case. A well-formed IP with no matching record returns HTTP 200 with "status": "Error" in the JSON. Checking only resp.StatusCode is not enough; Lookup treats Status != "OK" as an error so callers have a single error path. HTTP-level failures are separate: 401 for a bad key, 429 when you're over quota, 400 for a malformed request.
  • Reusable client. One Client with one http.Client gives you connection pooling for free. Don't construct a new http.Client per lookup.

Middleware: Enriching Requests with a Country Code

The common production pattern is not "look up an arbitrary IP" but "know which country the current visitor is in" - for localizing prices, gating features, or flagging datacenter traffic. In Go that's a natural fit for net/http middleware. Add this to the same package as the client above:

package main

import (
	"context"
	"net"
	"net/http"
	"strings"
	"time"
)

type ctxKey string

const countryCtxKey ctxKey = "visitorCountry"

func clientIP(r *http.Request) string {
	// Only trust X-Forwarded-For when a proxy you control sets it;
	// the leftmost entry is the original client.
	if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
		return strings.TrimSpace(strings.Split(fwd, ",")[0])
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

func WithCountry(client *Client, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
		defer cancel()

		country := ""
		if info, err := client.Lookup(ctx, clientIP(r)); err == nil && info.Location != nil {
			country = info.Location.CountryCode
		}

		next.ServeHTTP(w, r.WithContext(
			context.WithValue(r.Context(), countryCtxKey, country)))
	})
}

func CountryFrom(r *http.Request) string {
	if c, ok := r.Context().Value(countryCtxKey).(string); ok {
		return c
	}
	return ""
}

Wiring it up:

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "hello, visitor from %q\n", CountryFrom(r))
})
http.ListenAndServe(":8080", WithCountry(client, mux))

Notes on this design:

  • It degrades gracefully. A failed or slow lookup leaves the country empty and the request proceeds. A 2-second budget on the request path is already generous; geolocation should never take a page down.
  • Testing locally, your IP is a bogon. Requests from 127.0.0.1 return the minimal bogon body with no Location, which is exactly why the nil check exists. Behind a reverse proxy, remember r.RemoteAddr is the proxy - hence the X-Forwarded-For handling, with the caveat in the comment: the header is client-supplied and spoofable unless your own proxy overwrites it.
  • Cache in front of this for real traffic. Calling the API once per request is wasteful when the same visitor makes dozens of requests per session. An LRU or a simple map[string]string guarded by a sync.RWMutex with a TTL cuts the volume by orders of magnitude. (If you do use a plain map, keep the mutex - a concurrent map write is a fatal runtime error in Go, not just a data race.)

Beyond Location: ASN and Threat Flags

The same response carries the network's ASN and owning organization, which is often more actionable than city-level location. is_datacenter: true plus an ASN belonging to a cloud provider is a strong signal you're talking to a bot or a scraper rather than a person on a residential connection. You can explore any network interactively with the free ASN lookup tool, and GET /v1/asn/{asn} serves the same registration data over the API (it returns 404 for an ASN that doesn't exist, so handle that status too).

Wrapping Up

That's a complete golang IP geolocation setup in about a hundred lines of standard library code: typed structs matching the real response, a context-aware client that distinguishes HTTP errors from in-band lookup errors, and middleware that enriches every request with a country code without adding a hard dependency on the network being up. The full endpoint reference is in the documentation. To run the examples against live data, create a free account - 1,000 requests per day, no card required.

Ivan
About the author
Ivan, Founder, The IP API.
// READY_WHEN_YOU_ARE

Ready to start using The IP API?

Unlock accurate, reliable IP geolocation. Integrate in minutes to enhance personalization, performance, and security.