Integrations · Django
IPAPI.GR for Django
A middleware class sets request.geo on every request: country, region, city, timezone, ASN and ISP, plus the risk flags on paid plans. It uses your configured cache, times out after 1.5 seconds and fails open.
- Written for
- Django 4.2 and later, Python 3.10+
- Runs in
- Middleware
- Client IP from
- REMOTE_ADDR (or your proxy's entry)
01 · Setup
4 steps, copy and paste.
Every lookup runs on your server or edge, so the key never reaches a browser. Each example times out quickly, caches answers per IP and lets the visitor through if the lookup fails.
-
Install requests and set the key
The snippet uses requests with a shared session, so connections are reused.
ShellShell pip install requests export IPAPI_KEY="your_api_key_here"
-
Add the middleware
Non-public addresses are skipped with the standard ipaddress module; answers are cached for an hour.
myapp/ipapi.pyPython # myapp/ipapi.py: Django middleware that sets request.geo for the visitor's IP. # pip install requests. Set IPAPI_KEY in the environment. import ipaddress import os import requests from django.core.cache import cache LOOKUP = "https://ipapi.gr/api/lookup/" TTL = 60 * 60 # keep answers for an hour FIELDS = ("country", "country_code", "region", "city", "timezone", "asn", "isp") _session = requests.Session() def _known(value): # Missing values come back as the string "No Data". return None if value in (None, "", "No Data") else value def client_ip(request): # REMOTE_ADDR is the address that connected to Django. Behind exactly one # reverse proxy you control, use the last X-Forwarded-For entry it appended: # return request.META["HTTP_X_FORWARDED_FOR"].split(",")[-1].strip() return request.META.get("REMOTE_ADDR", "") def lookup(ip): try: addr = ipaddress.ip_address(ip) except ValueError: return None if not addr.is_global: # private, loopback, link-local: nothing to look up return None cache_key = f"ipapi:{addr}" data = cache.get(cache_key) if data is not None: return data try: r = _session.get(LOOKUP + str(addr), params={"key": os.environ["IPAPI_KEY"]}, timeout=1.5) r.raise_for_status() body = r.json() except (requests.RequestException, ValueError): return None # timeout, network or API error: fail open data = {field: _known(body.get(field)) for field in FIELDS} data["risk"] = body.get("risk") # {"is_ai", "is_crawler", "is_scanner"} on paid plans only cache.set(cache_key, data, TTL) return data class IpapiMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): request.geo = lookup(client_ip(request)) return self.get_response(request) -
Register it
Add it after Django's own middleware.
settings.pyPython # settings.py MIDDLEWARE = [ # ... Django's own middleware first "myapp.ipapi.IpapiMiddleware", ] # lookup() uses the default cache. Django falls back to per-process memory; # with several workers, a shared cache (Redis, Memcached) saves more requests. -
Use request.geo in a view
The view also shows the risk-flag pattern (paid plans) for self-declared AI crawlers.
myapp/views.pyPython # myapp/views.py import re from django.http import HttpResponseForbidden, JsonResponse AI_CLAIM = re.compile(r"GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|Claude-User|PerplexityBot|Perplexity-User", re.I) def home(request): geo = request.geo or {} risk = geo.get("risk") # Paid plans: a request that claims to be an AI crawler must come from a known AI crawler address. if risk and AI_CLAIM.search(request.headers.get("User-Agent", "")) and not risk["is_ai"]: return HttpResponseForbidden("Unverified crawler") return JsonResponse({"country": geo.get("country_code"), "timezone": geo.get("timezone")})
02 · The response
What you get back.
Free keys return location and network data: country, country_code, region, city, zipcode, latitude, longitude, timezone, isp, organization and asn.
Keys on any paid plan add risk.is_ai, risk.is_crawler and risk.is_scanner, which tell you when an address belongs to a known AI crawler, search crawler or internet scanner. The examples use is_ai to refuse requests whose user agent claims an AI crawler while the address isn't one. false means "not a known bot", not "human".
All response fields · AI crawlers we recognise · Bot signal coverage
03 · Pitfalls
What usually goes wrong.
- Pick one client-IP source and trust it
- REMOTE_ADDR is correct when Django faces the internet. Behind exactly one proxy you control, use the last X-Forwarded-For entry it appended, as the comment shows. The first entry can be anything the client sent.
- Async views
- The middleware is synchronous. Django runs it fine under ASGI, but for an async stack, an httpx.AsyncClient version avoids blocking the event loop.
- Localhost has no location
- In development the client is 127.0.0.1 or ::1. The snippets skip private and loopback addresses instead of spending a request on them.
- Website restrictions block server calls
- A key restricted to "Allowed websites" needs an Origin or Referer header, which server-side code does not send, so the call is refused. For a server key, leave websites empty and restrict by your server's outgoing IP address instead.
- "No Data" is a string, not null
- When no value is known for a field, the field is still present and set to "No Data". Every snippet converts it to null before you use it.
04 · Questions
Good to know.
Which cache does it use?
Django's default cache. Without configuration that is per-process memory; a shared Redis or Memcached cache saves more requests when you run several workers.
What happens when the API is slow or down?
After 1.5 seconds the request continues with request.geo set to None. Failures are not cached, so the next request tries again.
Do I need a paid plan?
No. Location and network fields work with a free key. request.geo["risk"] is None on free keys.
Other integrations
Ship it today.
Free: 1.000 requests a day for location and network data. Bot flags and higher limits on every paid plan, from 9,00 € a month (VAT included).