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.Clienthas a 10-second ceiling as a safety net, and each call takes acontext.Contextso the caller sets the real deadline.http.NewRequestWithContextmeans 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 onlyresp.StatusCodeis not enough;LookuptreatsStatus != "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
Clientwith onehttp.Clientgives you connection pooling for free. Don't construct a newhttp.Clientper 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.1return the minimal bogon body with noLocation, which is exactly why the nil check exists. Behind a reverse proxy, rememberr.RemoteAddris the proxy - hence theX-Forwarded-Forhandling, 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]stringguarded by async.RWMutexwith 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.