Get a visitor's country from their IP address in Node.js, Python, PHP and Go

Looking up a country from an IP takes one HTTP request. Getting the right IP, handling IPv6 and private addresses, caching results and failing gracefully takes a little more. Working examples in four languages.

On this page The request

You want to show prices in the visitor's currency, pre-fill a country dropdown, apply regional rules, or tag a sign-up with where it came from. All of these start with the same step: turn the visitor's IP address into a country.

The lookup itself is one HTTP request. This guide shows it in Node.js, Python, PHP and Go against the IPAPI.GR API, and then covers the parts that cause bugs in production: finding the real client IP, IPv6, private addresses, caching, timeouts and rate limits.

The request

Every lookup is a single GET with your API key:

bash
curl "https://fast.ipapi.gr/api/lookup/8.8.8.8?key=YOUR_API_KEY"
json
{
  "ip": "8.8.8.8",
  "country": "United States",
  "country_code": "US",
  "city": "Mountain View",
  "region": "California",
  "latitude": 37.436551599813335,
  "longitude": -122.09383799087185,
  "timezone": "America/Los_Angeles",
  "isp": "Google LLC",
  "organization": "Google LLC",
  "asn": "15169",
  "zipcode": "94043",
  "timestamp": "2025-05-13T08:29:59.370Z"
}

country_code is the two-letter code. Use it for logic and keep country for display. The same endpoint accepts IPv6 addresses and domain names. A domain is resolved to its first IPv4 (A) record, and the response then also contains domain. Every field is described in the API reference. You can get a free key in a minute; the daily allowance for each plan is on the pricing page.

Node.js

Node 18+ has fetch built in. This helper adds a timeout and a short in-memory cache:

js
const API_KEY = process.env.IPAPI_KEY;
const cache = new Map(); // ip -> { data, at }
const TTL_MS = 6 * 60 * 60 * 1000;

export async function lookupIp(ip) {
  const hit = cache.get(ip);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.data;

  const url = `https://fast.ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${API_KEY}`;
  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
  const data = await res.json();
  if (!res.ok) {
    const err = new Error(data.message || `HTTP ${res.status}`);
    err.status = res.status;
    throw err;
  }
  cache.set(ip, { data, at: Date.now() });
  return data;
}

Using it in Express, with a fallback when the lookup fails:

js
import express from 'express';
import { lookupIp } from './lookup.js';

const app = express();
app.set('trust proxy', 1); // see "Finding the real client IP" below

app.get('/', async (req, res) => {
  let country = null;
  try {
    const code = (await lookupIp(req.ip)).country_code;
    country = code && code !== 'No Data' ? code : null; // missing fields are "No Data"
  } catch (err) {
    console.warn('geo lookup failed:', err.message);
  }
  res.send(country ? `Hello, visitor from ${country}` : 'Hello!');
});

app.listen(3000);

Express may report IPv4 clients as ::ffff:203.0.113.7 (an IPv4-mapped IPv6 address). Strip the prefix if you store or compare addresses: ip.replace(/^::ffff:/, '').

Python

With requests:

python
import os
from functools import lru_cache

import requests

API_KEY = os.environ["IPAPI_KEY"]
session = requests.Session()

@lru_cache(maxsize=10_000)
def lookup_ip(ip: str) -> dict:
    r = session.get(
        f"https://fast.ipapi.gr/api/lookup/{ip}",
        params={"key": API_KEY},
        timeout=3,
    )
    data = r.json()
    if r.status_code != 200:
        raise RuntimeError(f"{r.status_code}: {data.get('message')}")
    return data

print(lookup_ip("8.8.8.8")["country_code"])  # US

lru_cache never expires entries, which is fine for a batch job. For a long-running web app, use a cache with a TTL (for example cachetools.TTLCache) so changed allocations are picked up.

In Django, the client address is request.META["REMOTE_ADDR"]. In Flask it is request.remote_addr. Behind a proxy, wrap the app in Werkzeug's ProxyFix with the exact number of proxies you run.

PHP

With the cURL extension, which is installed almost everywhere:

php
<?php
function lookup_ip(string $ip): array {
    $url = 'https://fast.ipapi.gr/api/lookup/' . rawurlencode($ip)
         . '?key=' . urlencode(getenv('IPAPI_KEY'));
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 3,
        CURLOPT_CONNECTTIMEOUT => 2,
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($body === false) {
        throw new RuntimeException('network error');
    }
    $data = json_decode($body, true);
    if ($status !== 200) {
        throw new RuntimeException($data['message'] ?? "HTTP $status");
    }
    return $data;
}

$geo = lookup_ip($_SERVER['REMOTE_ADDR']);
$country = $geo['country'] ?? 'No Data';
echo htmlspecialchars($country === 'No Data' ? 'Unknown' : $country);

If your host has APCu, wrap the call in apcu_fetch / apcu_store with a TTL to avoid repeating the lookup for the same visitor.

Go

go
package main

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

type Geo struct {
    IP          string  `json:"ip"`
    Country     string  `json:"country"`
    CountryCode string  `json:"country_code"`
    City        string  `json:"city"`
    ISP         string  `json:"isp"`
    ASN         string  `json:"asn"`
    Latitude    float64 `json:"latitude"`
    Longitude   float64 `json:"longitude"`
    Message     string  `json:"message"`
}

var client = &http.Client{Timeout: 3 * time.Second}

func lookupIP(ip string) (*Geo, error) {
    u := "https://fast.ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY"))
    resp, err := client.Get(u)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var g Geo
    if err := json.NewDecoder(resp.Body).Decode(&g); err != nil {
        return nil, err
    }
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("lookup failed: %d %s", resp.StatusCode, g.Message)
    }
    return &g, nil
}

func main() {
    g, err := lookupIP("8.8.8.8")
    if err != nil {
        panic(err)
    }
    fmt.Println(g.CountryCode, g.ISP)
}

asn is a string in the response, so the struct uses string.

Finding the real client IP

This is where most geolocation bugs come from. If your app sits behind a load balancer, a reverse proxy or a CDN, the TCP peer is the proxy, and you'll geolocate your own infrastructure.

  • Use your framework's proxy setting, not a hand-parsed header: Express trust proxy, Werkzeug ProxyFix, Laravel TrustProxies, Nginx realip.
  • Trust only the number of proxies you actually run. X-Forwarded-For is a list the client can prepend to. The right address is the one added by your outermost proxy, counted from the right.
  • CDN headers are fine if you only accept traffic from the CDN. Cloudflare sends CF-Connecting-IP. Trusting it on a server that is also reachable directly lets anyone set it.
Warning

Never take the client IP from a header without restricting who may set it. A spoofed X-Forwarded-For lets a user pick their own country, and bypass any per-IP rate limit you have.

Edge cases to handle

  • Private and reserved addresses. 10.0.0.0/8, 192.168.0.0/16, 127.0.0.1, ::1 and similar have no public location. Skip the lookup for them. In Node, net.BlockList can hold the reserved ranges. In Python, ipaddress.ip_address(ip).is_global does the check.
  • Missing fields. When no data source has a value, the field is the string "No Data" (never null or absent). It's more common for city and zipcode than for country_code. Treat "No Data" as unknown and fall back.
  • Your own server's IP. To geolocate the machine making the call, use /api/lookup/self instead of discovering your public IP first.
  • Rate limits. Over your daily limit the API returns 429 with a resetTime. Limits reset at midnight UTC. Cache results, and treat 429 as "unknown country" rather than an error page. Check usage with /api/status, which doesn't count against the limit.
  • Timeouts. Put a short timeout on the call and never block a page render indefinitely on geolocation.

How accurate is it?

Country-level results are generally reliable for fixed broadband and data-centre addresses. City-level results are approximate, and mobile carriers, VPNs and corporate proxies can place a user far from where they really are. Use IP geolocation for defaults and analytics, and let users override it. We go into this in how accurate is IP geolocation.

Beyond the country

The same response gives you more to work with:

  • timezone for showing local times without asking.
  • asn, isp and organization to tell a home connection from a data centre. See ASN lookup by IP.
  • On Pro, the risk object tells you whether the address belongs to a verified AI crawler, search crawler or internet scanner. See how to verify AI crawler IPs.

FAQ

Can I call the API from the browser?

You can, but the key is then visible to anyone who opens developer tools. Call it from your server, or from an edge function, and pass only the result to the page.

Do I need to look up every request?

No. Look up once per session or per IP and cache it. Country data for an address rarely changes from one hour to the next.

Does it work with IPv6?

Yes. Pass the IPv6 address as-is in the path, for example /api/lookup/2001:4860:4860::8888.

Next: redirect visitors by country without locking out search engines.