The IP API The IP API
// Guide

How to Get a User's Location from an IP Address in Python

Jul 18th, 2026 // 7 min read
How to Get a User's Location from an IP Address in Python

The fastest way to get a user's location from an IP address in Python is to call a Python IP geolocation API: send an HTTP GET with the IP, get back JSON with the country, city, coordinates, and timezone. This guide uses The IP API and walks through a plain requests example, a FastAPI endpoint that geolocates the visitor making the request, error handling that actually matches how the API behaves, and a batch endpoint for enriching a whole CSV of IPs at once.

You'll need an API key - the free plan gives you 1,000 requests per day with no card, and you can sign up here.

A Python IP Geolocation API Call with requests

Install requests if you don't have it (pip install requests), then the whole lookup is one GET request:

import requests

API_KEY = "YOUR_API_KEY"


def lookup_ip(ip: str) -> dict | None:
    resp = requests.get(
        f"https://api.theipapi.com/v1/ip/{ip}",
        params={"api_key": API_KEY},
        timeout=5,
    )
    resp.raise_for_status()
    data = resp.json()
    if data["status"] != "OK":
        return None
    return data["body"]


info = lookup_ip("8.8.8.8")
if info and "location" in info:
    loc = info["location"]
    print(loc["country_code"], loc["city"], loc["timezone"])

Running this prints US Mountain View America/Los_Angeles. The full response body contains more than location data:

{
  "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",
      "country": "US",
      "created": "2005-11-23",
      "network": "8.8.8.0/24",
      "org_name": "Google LLC",
      "rir": "ARIN",
      "updated": "2019-10-31"
    },
    "company": {
      "name": "Google LLC",
      "address": "1600 Amphitheatre Parkway, Mountain View, CA, US",
      "network": "8.8.8.0 - 8.8.8.255",
      "route": "8.8.8.0/24"
    },
    "is_bogon": false,
    "is_datacenter": true,
    "is_vpn": false
  },
  "response_time_ms": 10
}

Alongside location you get the ASN and owning company, plus three boolean flags: is_bogon (reserved or unroutable address), is_datacenter, and is_vpn. Those flags are what you want if the goal is less "where is this user" and more "is this user hiding behind a datacenter proxy".

Error Handling That Matches Real API Behavior

There are two distinct failure modes, and code that only checks one of them will break in production:

  1. HTTP-level errors. A bad API key returns 401, a malformed request 400, a blown quota 429, and a restricted account 403. resp.raise_for_status() turns these into a requests.HTTPError.
  2. HTTP 200 with "status": "Error". A syntactically valid IP with no matching record still returns 200, but the JSON status field is "Error" instead of "OK". If you skip this check and index into body, you'll get a KeyError at the worst possible time.

There's also a third edge case worth knowing: bogon IPs (private ranges like 192.168.1.1 per RFC 1918, loopback, and other reserved space) return a minimal body containing only ip and is_bogon. That's why the example above checks "location" in info before reading it - during local development your visitor IP is often 127.0.0.1, which is exactly this case.

A more defensive version of the lookup:

import requests

API_KEY = "YOUR_API_KEY"


def safe_lookup(ip: str) -> dict | None:
    try:
        resp = requests.get(
            f"https://api.theipapi.com/v1/ip/{ip}",
            params={"api_key": API_KEY},
            timeout=5,
        )
        resp.raise_for_status()
    except requests.RequestException as exc:
        print(f"lookup failed for {ip}: {exc}")
        return None

    data = resp.json()
    if data.get("status") != "OK":
        return None
    return data.get("body")

Geolocating the Visitor in a FastAPI Endpoint

The most common real-world use is not looking up an arbitrary IP but the IP of the user currently hitting your server - to preselect a country, localize prices, or set a timezone. Here's a FastAPI endpoint that does it with httpx (pip install fastapi httpx uvicorn):

import httpx
from fastapi import FastAPI, Request

app = FastAPI()
API_KEY = "YOUR_API_KEY"


def client_ip(request: Request) -> str:
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        return forwarded.split(",")[0].strip()
    return request.client.host


@app.get("/api/geo")
async def geo(request: Request):
    ip = client_ip(request)
    async with httpx.AsyncClient(timeout=5) as client:
        resp = await client.get(
            f"https://api.theipapi.com/v1/ip/{ip}",
            params={"api_key": API_KEY},
        )
    if resp.status_code != 200:
        return {"ok": False}
    data = resp.json()
    body = data.get("body") or {}
    location = body.get("location")
    if data.get("status") != "OK" or not location:
        return {"ok": False}
    return {
        "ok": True,
        "country": location["country_code"],
        "city": location["city"],
        "timezone": location["timezone"],
    }

Run it with uvicorn main:app and hit /api/geo - though from localhost you'll get the bogon case described above, so {"ok": false} is the correct answer there. Deploy it behind a real domain to see actual visitor data, or check what your own public IP resolves to with the What Is My IP tool.

The X-Forwarded-For pitfall. If your app sits behind a reverse proxy or load balancer (nginx, Caddy, an AWS ALB), request.client.host is the proxy's IP, not the user's. The original client IP arrives in the X-Forwarded-For header, which is a comma-separated chain where the leftmost entry is the original client. Two things to be careful about:

  • Only trust the header when a proxy you control actually sets it. A client talking directly to your server can send any X-Forwarded-For value it likes, so a direct-to-internet deployment should ignore it entirely.
  • If there are multiple proxies, each appends an entry. The safest general rule when you control the proxy chain is to take the rightmost IP that isn't one of your own proxies; the simple leftmost-entry approach above is fine when exactly one trusted proxy fronts your app and overwrites or sanitizes the header.

Batch Lookups: Enriching a CSV of IP Addresses

Looking up IPs one at a time is fine interactively, but for a log export or an analytics dump you want POST /v1/ip/batch, which accepts up to 100 IPs per request. Duplicates within a request are deduplicated and only counted once against your quota, and the quota check is all-or-nothing - if the batch doesn't fit in your remaining quota, the whole request is rejected with a 429 rather than partially processed. The response is a dictionary keyed by IP, where each value has the same status/body shape as a single lookup, so one unresolvable IP doesn't fail the rest.

Here's a script that reads visitors.csv (with an ip column), looks every address up in chunks of 100, and writes visitors_enriched.csv with country, city, and datacenter columns added:

import csv
import requests

API_KEY = "YOUR_API_KEY"
BATCH_URL = "https://api.theipapi.com/v1/ip/batch"


def batch_lookup(ips: list[str]) -> dict:
    resp = requests.post(
        BATCH_URL,
        params={"api_key": API_KEY},
        json={"ips": ips},
        timeout=30,
    )
    resp.raise_for_status()
    # The per-IP entries live under the "results" key of the envelope
    return resp.json()["results"]


with open("visitors.csv", newline="") as f:
    rows = list(csv.DictReader(f))

unique_ips = sorted({row["ip"] for row in rows})

results: dict = {}
for i in range(0, len(unique_ips), 100):
    chunk = unique_ips[i : i + 100]
    results.update(batch_lookup(chunk))

fieldnames = list(rows[0].keys()) + ["country_code", "city", "is_datacenter"]
with open("visitors_enriched.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    for row in rows:
        entry = results.get(row["ip"], {})
        body = entry.get("body") or {}
        location = body.get("location") or {}
        row["country_code"] = location.get("country_code", "")
        row["city"] = location.get("city", "")
        row["is_datacenter"] = body.get("is_datacenter", "")
        writer.writerow(row)

print(f"enriched {len(rows)} rows using {len(unique_ips)} lookups")

Deduplicating before sending matters: a day of web logs might contain 50,000 rows but only a few thousand distinct IPs, and you only pay quota for the distinct ones. For a deeper look at chunking strategy, quota math, and per-IP error handling, see the dedicated post on the batch IP geolocation API.

Practical Notes

  • Cache your lookups. IP-to-location mappings change slowly. If the same IP can hit your endpoint many times per session, cache the result (in-process dict, Redis, whatever you already run) for hours rather than re-querying.
  • Set timeouts. Both examples above pass explicit timeouts. A geolocation call on the request path should fail fast and degrade gracefully, never hang a page load.
  • Treat location as a hint, not a fact. Corporate VPNs, mobile carriers, and datacenter egress IPs all shift apparent location. The is_vpn and is_datacenter flags tell you when to lower your confidence.

That's the whole toolkit: single lookups with requests, visitor geolocation in FastAPI with correct proxy handling, error handling for both HTTP errors and the 200-with-Error case, and batch enrichment for bulk data. The full endpoint reference lives in the documentation. To try it against real traffic, 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.