IP Geolocation in Python: Official SDK, REST Calls and Framework Recipes

Updated

Short answer

Install the official package with pip install iplookupapi, create iplookupapi.Client(os.environ[“IPLOOKUPAPI_KEY”]) and call client.info(“8.8.8.8”). It returns a dict whose data key holds the country, region, city, coordinates, ISP, ASN and time zone of the address. For field filtering, reverse DNS, batches of up to 1,000 IPs and timeouts, call the REST API at https://api.iplookupapi.com/v1 directly with requests or httpx.

On this page

The official iplookupapi package does a GeoIP lookup for any IPv4 or IPv6 address in two lines of Python: location, ISP, ASN and time zone. This page covers the SDK calls, the plain REST calls for what the SDK leaves out, error handling with retries, and recipes for Flask, Django, FastAPI, CSV files and caching. All code reads the key from the IPLOOKUPAPI_KEY environment variable. Other languages are on the official SDKs overview.

Install the iplookupapi package

Install the package from PyPI:

Shell
pip install iplookupapi
# or
uv add iplookupapi
# or
poetry add iplookupapi

It pulls in requests and everapi, a small shared client. The package metadata lists Python 2.7 and 3.3 to 3.9; the examples here need Python 3.9 or later. The FastAPI recipe also needs fastapi, uvicorn and httpx, the CSV recipe pandas.

Set your API key

Register for a free key, then put it in an environment variable instead of your source code:

Shell
export IPLOOKUPAPI_KEY="your-api-key"

In PowerShell, use $env:IPLOOKUPAPI_KEY = "your-api-key". In production, use your platform’s secrets manager.

Look up an IP address in Python

Python
import os

import iplookupapi

client = iplookupapi.Client(os.environ["IPLOOKUPAPI_KEY"])

result = client.info("8.8.8.8")  # a dict parsed from the JSON response
data = result["data"]

print(data["location"]["country"]["name"])
print(data["location"]["city"]["name"])
print(data["connection"]["asn"], data["connection"]["isp"])
print(data["timezone"]["id"])

client.info() returns a plain dict with the result under data. The SDK sends the key in the apikey HTTP header, which keeps it out of URLs and logs. The fields you will use most:

Path in dataExample contentPlan
location.country.alpha2, .nameCountry code and nameFree and up
location.region.name, location.city.name, location.zipRegion, city, postal codeFree and up
location.latitude, location.longitudeApproximate coordinatesFree and up
location.country.currencies, .calling_codesCurrency and dialing code dataFree and up
connection.asn, .isp, .organizationNetwork operatorFree and up
timezone.id, .gmt_offset, .is_daylight_savingFor example Europe/Vienna; offset in secondsFree and up
range_type.typeFor example PUBLICFree and up
network, company, abuseHosting, CDN, mobile carrier, owner, abuse contactBasic and up
security.is_vpn, .is_proxy, .is_tor, .threat_scoreAnonymizer and threat flagsProfessional and up

The info endpoint documentation lists every field.

Your own IP, other languages and your quota

Python
import os

import iplookupapi

client = iplookupapi.Client(os.environ["IPLOOKUPAPI_KEY"])

own = client.info()  # no IP: the API answers for the address the request came from
german = client.info("8.8.8.8", "de")  # positional arguments: ip, language
status = client.status()  # free, does not count against your quota

print(own["data"]["ip"])
print(german["data"]["location"]["country"]["name_translated"])
print(status["quotas"]["month"]["remaining"])

Without an IP, you get the public address of the machine running the script: at home, your router’s public IP, not a local address such as 192.168.1.20; on a server, the server’s IP, not a visitor’s. Private addresses come back with their range_type and no location. Place names are available in de, en, es, fr, ja, pt, ru and zh.

What the SDK covers and when to call the REST API

Version 0.1 of the package wraps two endpoints, /info (with ip and language only) and /status:

TaskSDKREST API
Look up one IPclient.info("8.8.8.8")GET /v1/info?ip=8.8.8.8
Caller’s own IPclient.info()GET /v1/info
Languageclient.info(ip, "de")language=de
Quota and planclient.status()GET /v1/status
Return selected fields onlynot availablefields=ip,location.country.alpha2
Reverse DNS hostnamenot availablehostname=true
Up to 1,000 IPs per requestnot availablePOST /v1/batch
Timeouts and retriesnot set by the SDKset in your HTTP client

A direct call with requests looks like this:

Python
import os

import requests

resp = requests.get(
    "https://api.iplookupapi.com/v1/info",
    params={"ip": "8.8.8.8", "fields": "ip,location.country.alpha2,timezone.id"},
    headers={"apikey": os.environ["IPLOOKUPAPI_KEY"]},
    timeout=10,
)
resp.raise_for_status()
print(resp.json()["data"])

A requests helper with timeouts, retries and batch

For production code, save this module as iplookup.py. It adds timeouts, retries with backoff, field filtering, batches of 1,000 and a quota check. The recipes below import it.

Python
import ipaddress
import os
import random
import time

import requests

API_BASE = "https://api.iplookupapi.com/v1"

session = requests.Session()
session.headers["apikey"] = os.environ["IPLOOKUPAPI_KEY"]


class IPLookupError(Exception):
    def __init__(self, status, body):
        self.status = status
        self.body = body
        self.code = (body.get("error") or {}).get("code")
        super().__init__(f"HTTP {status}: {body.get('message')}")


def _call(method, path, retries=4, **kwargs):
    kwargs.setdefault("timeout", (3.05, 15))  # (connect, read) in seconds
    for attempt in range(retries + 1):
        try:
            resp = session.request(method, API_BASE + path, **kwargs)
        except (requests.ConnectionError, requests.Timeout):
            if attempt == retries:
                raise
        else:
            if resp.status_code == 200:
                return resp.json()
            try:
                body = resp.json()
            except ValueError:
                body = {"message": resp.text}
            code = (body.get("error") or {}).get("code")
            # Retry server errors and the per-minute limit, never a used-up quota.
            retry = resp.status_code >= 500 or (
                resp.status_code == 429
                and code != "quota_exceeded"
                and "quota" not in body
            )
            if not retry or attempt == retries:
                raise IPLookupError(resp.status_code, body)
        time.sleep(min(30, 2 ** attempt) + random.random())  # backoff + jitter


def lookup(ip=None, fields=None, language=None, hostname=False):
    """One address (or the caller's own IP if ip is None). Returns the data object."""
    params = {"ip": ip, "fields": fields, "language": language}
    if hostname:
        params["hostname"] = "true"
    return _call("GET", "/info", params=params)["data"]


def lookup_many(ips, fields=None, language=None, chunk_size=1000):
    """Any number of addresses, sent in POST batches of up to 1,000."""
    valid, results, errors = [], {}, {}
    for ip in dict.fromkeys(ips):  # dedupe, keep order
        try:
            ipaddress.ip_address(ip)
            valid.append(ip)
        except ValueError:  # a chunk without any valid IP would fail with 422
            errors[ip] = "Not a valid IP address."
    for start in range(0, len(valid), chunk_size):
        body = _call(
            "POST", "/batch",
            params={"fields": fields, "language": language},
            json={"ips": valid[start:start + chunk_size]},
            timeout=(3.05, 60),
        )
        results.update(body.get("data") or {})
        errors.update(body.get("errors") or {})
    return results, errors


def remaining_quota():
    """GET /v1/status is free and does not count against the quota."""
    return _call("GET", "/status")["quotas"]["month"]["remaining"]


def dig(obj, path):
    """Safe dot-path access: dig(data, "location.country.alpha2")."""
    for key in path.split("."):
        if not isinstance(obj, dict):
            return None
        obj = obj.get(key)
    return obj

How it behaves:

  • Retries cover connection errors, timeouts, server errors and 429 responses from the per-minute limit (10 a minute on the Free plan). A 429 for a used-up monthly quota (code quota_exceeded or a quota object) stops the helper instead.
  • Batch results come back keyed by address. Each valid address counts as one request and duplicates count once. The helper filters out items that are not IP addresses before sending; the API would list them in errors without billing them. The batch IP lookup API page covers billing and limits; the batch documentation lists all parameters.

Handle errors: 401, 403, 404, 422 and 429

StatusMeaningSDK 0.1 behaviorWhat to do
401Missing or invalid keyraises IncorrectApikeyCheck IPLOOKUPAPI_KEY
403Not in your plan, referrer not allowed, or key for another productraises NotAllowedCheck plan and key settings
404No data for this IPno exception, dict without dataTreat as “no result”
422Validation error, for example an invalid IPraises ApiErrorFix the input
429Rate limit or monthly quota reachedraises QuotaExceeded or RateLimitExceededWait, or upgrade the plan

On a 429, the SDK picks the exception from the X-RateLimit-Remaining-Quota-Month response header; without that header, it returns the error body like a 404 or a server error. Error bodies carry a message and, except for 422 and 500, an error.code such as invalid_api_key or quota_exceeded. With the SDK, catch its exceptions and check for the data key:

Python
import os

import iplookupapi
from everapi.exceptions import (
    ApiError, IncorrectApikey, NotAllowed, QuotaExceeded, RateLimitExceeded,
)

client = iplookupapi.Client(os.environ["IPLOOKUPAPI_KEY"])


def country_code(ip):
    try:
        result = client.info(ip)
    except IncorrectApikey:
        raise SystemExit("Check IPLOOKUPAPI_KEY (HTTP 401).")
    except NotAllowed:
        raise SystemExit("Your plan or key does not allow this request (HTTP 403).")
    except QuotaExceeded:
        raise SystemExit("Monthly quota used up (HTTP 429).")
    except RateLimitExceeded:
        return None  # per-minute limit: retry later
    except ApiError as exc:  # HTTP 422: exc.args[1] holds the validation errors
        print("Invalid request:", exc.args[1])
        return None
    if "data" not in result:  # for example HTTP 404 "No data found for this IP"
        return None
    return result["data"]["location"]["country"]["alpha2"]

Warning

Version 0.1 of the SDK calls requests without a timeout, so a stalled connection can block a worker indefinitely. In web handlers, use the iplookup.py helper or another client with an explicit timeout.

Recipes for Flask, Django and FastAPI

Behind a reverse proxy, load balancer or CDN, your app sees the proxy’s address. Trust X-Forwarded-For only for the hops you control, or anyone can fake the header. The guide to the real client IP behind proxies and CDNs explains the header chain, and get the IP address in Python covers the local and public IP and the ipaddress module.

Flask: geolocate the visitor behind a proxy

Werkzeug’s ProxyFix middleware sets request.remote_addr from the header. Set x_for to the number of proxies in front of the app:

Python
from flask import Flask, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix

from iplookup import IPLookupError, lookup

app = Flask(__name__)
# Exactly one reverse proxy (for example nginx) sets X-Forwarded-For.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)

FIELDS = "ip,location.country.alpha2,location.city.name,timezone.id"


@app.get("/geo")
def geo():
    try:
        data = lookup(request.remote_addr, fields=FIELDS)
    except IPLookupError as exc:
        return jsonify(error=str(exc)), 502
    return jsonify(data)

Django: get the client IP with trusted proxies

Django puts the connecting address in request.META["REMOTE_ADDR"] and has no built-in proxy setting. List your proxies in your own setting, for example TRUSTED_PROXIES = ["127.0.0.1/32", "::1/128", "10.0.0.0/8"] in settings.py, and save this as utils.py in your app. It reads X-Forwarded-For only when the request comes from one of those proxies:

Python
import ipaddress

from django.conf import settings


def _parse(value):
    try:
        ip = ipaddress.ip_address(value.strip())
    except ValueError:
        return None
    return getattr(ip, "ipv4_mapped", None) or ip  # ::ffff:203.0.113.7 -> 203.0.113.7


def client_ip(request):
    """REMOTE_ADDR, or the right-most X-Forwarded-For entry that is not a trusted proxy."""
    trusted = [ipaddress.ip_network(n) for n in getattr(settings, "TRUSTED_PROXIES", [])]
    ip = _parse(request.META.get("REMOTE_ADDR", ""))
    hops = [h for h in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
    while ip is not None and hops and any(ip in net for net in trusted):
        ip = _parse(hops.pop())
    return str(ip) if ip is not None else None

The view. It checks for None, because lookup(None) would look up your server’s own address:

Python
from django.http import JsonResponse

from iplookup import IPLookupError, lookup
from .utils import client_ip


def geo(request):
    ip = client_ip(request)
    if ip is None:
        return JsonResponse({"error": "No valid client IP"}, status=400)
    try:
        data = lookup(ip, fields="ip,location.country.alpha2,timezone.id")
    except IPLookupError as exc:
        return JsonResponse({"error": str(exc)}, status=502)
    return JsonResponse(data)

FastAPI: IP lookup as a dependency

An async httpx client lives as long as the app, so requests reuse connections:

Python
import os
from contextlib import asynccontextmanager

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request

FIELDS = "ip,location.country.alpha2,location.city.name,timezone.id"


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(
        base_url="https://api.iplookupapi.com/v1",
        headers={"apikey": os.environ["IPLOOKUPAPI_KEY"]},
        timeout=httpx.Timeout(10.0, connect=3.0),
    )
    yield
    await app.state.http.aclose()


app = FastAPI(lifespan=lifespan)


async def visitor_geo(request: Request) -> dict:
    params = {"fields": FIELDS}
    if request.client:
        params["ip"] = request.client.host
    try:
        resp = await request.app.state.http.get("/info", params=params)
    except httpx.HTTPError:
        raise HTTPException(status_code=503, detail="IP lookup unavailable")
    if resp.status_code != 200:
        raise HTTPException(status_code=502, detail=f"IP lookup failed: HTTP {resp.status_code}")
    return resp.json()["data"]


@app.get("/geo")
async def geo(geo: dict = Depends(visitor_geo)):
    location = geo.get("location") or {}  # private and loopback addresses have no location
    return {"country": (location.get("country") or {}).get("alpha2"), "timezone": geo.get("timezone")}

Uvicorn trusts X-Forwarded-For only from the addresses in --forwarded-allow-ips, by default 127.0.0.1,::1. If your proxy runs on another host, start with uvicorn main:app --forwarded-allow-ips="10.0.0.5", using your proxy’s address.

Tip

On your development machine, the visitor address is 127.0.0.1, which has no location. Test with a public address passed by hand, or behind your real proxy.

Enrich a CSV or log file with batch lookups

With pandas, add country, city, ASN and ISP columns to a file with an ip column:

Python
import pandas as pd

from iplookup import dig, lookup_many

df = pd.read_csv("visits.csv", dtype={"ip": "string"})  # needs a column named "ip"
df["ip"] = df["ip"].str.strip()
fields = "ip,location.country.alpha2,location.city.name,connection.asn,connection.isp"

results, errors = lookup_many(df["ip"].dropna(), fields=fields)

columns = {
    "country": "location.country.alpha2",
    "city": "location.city.name",
    "asn": "connection.asn",
    "isp": "connection.isp",
}
for column, path in columns.items():
    df[column] = df["ip"].map(lambda ip: dig(results.get(ip), path))
df["asn"] = df["asn"].astype("Int64")

df.to_csv("visits_enriched.csv", index=False)
print(f"{len(results)} addresses enriched, {len(errors)} not valid IPs")

Without pandas, count visitor countries in a web server access log, where the client address is the first field:

Python
from collections import Counter

from iplookup import dig, lookup_many

with open("access.log", encoding="utf-8") as f:
    ips = [line.split(" ", 1)[0] for line in f if line.strip()]

results, errors = lookup_many(ips, fields="ip,location.country.alpha2")
countries = Counter(dig(results.get(ip), "location.country.alpha2") or "unknown" for ip in ips)
print(countries.most_common(10))

Call remaining_quota() before a large file: every unique valid address counts as one request, and the pricing page shows the monthly volume per plan.

Cache lookups to save requests

Visitors and logs repeat addresses, so cache results. functools.lru_cache works within one process for small, immutable values such as the country code. For data that should expire, use a time-to-live cache:

Python
import time
from functools import lru_cache

from iplookup import dig, lookup


@lru_cache(maxsize=10_000)
def country_of(ip):
    return dig(lookup(ip, fields="ip,location.country.alpha2"), "location.country.alpha2")


TTL_SECONDS = 6 * 3600
_cache = {}


def cached_lookup(ip, fields=None):
    key = (ip, fields)
    hit = _cache.get(key)
    if hit and hit[0] > time.monotonic():
        return hit[1]
    data = lookup(ip, fields=fields)
    _cache[key] = (time.monotonic() + TTL_SECONDS, data)
    return data

Location and network owner change rarely; security flags can change faster, so give them a shorter TTL. The dict above grows without a limit. With several workers or servers, use a shared cache such as Django’s cache.get_or_set() backed by Redis or Memcached.

Note

IP geolocation estimates where a network is, not where a person is. Read how accurate IP geolocation is before you base decisions on city-level results. The full API reference is in the documentation.

Frequently asked questions

How do I get the location of an IP address in Python?

Install the official package with pip install iplookupapi, then run client = iplookupapi.Client(os.environ[“IPLOOKUPAPI_KEY”]) and result = client.info(“8.8.8.8”). The country is in result[“data”][“location”][“country”][“name”], the city in result[“data”][“location”][“city”][“name”], and the time zone in result[“data”][“timezone”][“id”]. The location is an estimate for the network, not a street address.

Does the iplookupapi Python SDK support batch lookups?

Version 0.1 of the iplookupapi package offers two methods: info() for one address and status() for your quota. For batch lookups, send a POST request to https://api.iplookupapi.com/v1/batch with requests and a JSON body {“ips”: […]} of up to 1,000 addresses. The batch documentation lists the parameters.

How do I get the visitor's IP address in Flask, Django or FastAPI?

Read request.remote_addr in Flask, request.META[“REMOTE_ADDR”] in Django and request.client.host in FastAPI. Behind a reverse proxy or CDN, these show the proxy, so configure your framework to trust X-Forwarded-For only from your own proxies: ProxyFix in Flask, a trusted-proxy check in Django, –forwarded-allow-ips in Uvicorn. The guide to the real client IP behind proxies covers each setup.

Is there a free IP geolocation API for Python?

Yes. The iplookupapi Free plan includes 150 requests a month at up to 10 requests a minute, with location, connection, time zone and currency data, and batch lookups. The Python package and the REST API work the same on every plan. Paid plans add network, company, abuse and security data (see pricing).

How do I get my own public IP address and its location in Python?

Call client.info() without an IP address. The API then answers for the address the request came from, which is the public IP of the machine or network running your script. It is not your local address such as 192.168.1.20, and on a server it is the server’s IP, not a website visitor’s.

Why does a lookup return no location for some IP addresses?

Private and reserved addresses, such as 10.0.0.1, 192.168.1.20 or 127.0.0.1, are not routed on the public internet, so no location exists for them. The API returns their range_type without location data. A 404 response means the API has no data for that public address.