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.

  1. 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"
  2. 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)
  3. 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.
  4. 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".

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.

More in the docs and the FAQ.

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.

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).