IP Geolocation in Python: Official SDK, REST Calls and Framework Recipes
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
- Install the iplookupapi package
- Set your API key
- Look up an IP address in Python
- What the SDK covers and when to call the REST API
- A requests helper with timeouts, retries and batch
- Handle errors: 401, 403, 404, 422 and 429
- Recipes for Flask, Django and FastAPI
- Enrich a CSV or log file with batch lookups
- Cache lookups to save requests
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:
pip install iplookupapi
# or
uv add iplookupapi
# or
poetry add iplookupapiIt 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:
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
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 data | Example content | Plan |
|---|---|---|
location.country.alpha2, .name | Country code and name | Free and up |
location.region.name, location.city.name, location.zip | Region, city, postal code | Free and up |
location.latitude, location.longitude | Approximate coordinates | Free and up |
location.country.currencies, .calling_codes | Currency and dialing code data | Free and up |
connection.asn, .isp, .organization | Network operator | Free and up |
timezone.id, .gmt_offset, .is_daylight_saving | For example Europe/Vienna; offset in seconds | Free and up |
range_type.type | For example PUBLIC | Free and up |
network, company, abuse | Hosting, CDN, mobile carrier, owner, abuse contact | Basic and up |
security.is_vpn, .is_proxy, .is_tor, .threat_score | Anonymizer and threat flags | Professional and up |
The info endpoint documentation lists every field.
Your own IP, other languages and your quota
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:
| Task | SDK | REST API |
|---|---|---|
| Look up one IP | client.info("8.8.8.8") | GET /v1/info?ip=8.8.8.8 |
| Caller’s own IP | client.info() | GET /v1/info |
| Language | client.info(ip, "de") | language=de |
| Quota and plan | client.status() | GET /v1/status |
| Return selected fields only | not available | fields=ip,location.country.alpha2 |
| Reverse DNS hostname | not available | hostname=true |
| Up to 1,000 IPs per request | not available | POST /v1/batch |
| Timeouts and retries | not set by the SDK | set in your HTTP client |
A direct call with requests looks like this:
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.
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 objHow it behaves:
- Retries cover connection errors, timeouts, server errors and
429responses from the per-minute limit (10 a minute on the Free plan). A429for a used-up monthly quota (codequota_exceededor aquotaobject) 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
errorswithout 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
| Status | Meaning | SDK 0.1 behavior | What to do |
|---|---|---|---|
| 401 | Missing or invalid key | raises IncorrectApikey | Check IPLOOKUPAPI_KEY |
| 403 | Not in your plan, referrer not allowed, or key for another product | raises NotAllowed | Check plan and key settings |
| 404 | No data for this IP | no exception, dict without data | Treat as “no result” |
| 422 | Validation error, for example an invalid IP | raises ApiError | Fix the input |
| 429 | Rate limit or monthly quota reached | raises QuotaExceeded or RateLimitExceeded | Wait, 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:
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:
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:
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 NoneThe view. It checks for None, because lookup(None) would look up your server’s own address:
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:
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:
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:
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:
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 dataLocation 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.