PythonDjangoTutorialGeolocationAPI

IP Geolocation in Django: Country Detection, Currency, and Middleware

ApogeoAPI6 min read

Django developers often reach for GeoIP2 (MaxMind's Python library) for country detection. It works, but it requires downloading a 60MB binary database weekly, setting up a cron, and still doesn't give you live exchange rates. A single REST API call often works better — especially for projects that don't need city-level accuracy.

Setup

pip install requests  # already in most Django projects
# Or use httpx for async: pip install httpx

Add to settings.py:

APOGEOAPI_KEY = env('APOGEOAPI_KEY', default='')

GeoService — Reusable Client

# geo/services.py
import requests
from django.conf import settings
from django.core.cache import cache
import hashlib
import logging

logger = logging.getLogger(__name__)

GEO_API_BASE = 'https://api.apogeoapi.com/v1'

# Fallback for local/unknown IPs
_FALLBACK = {
    'countryCode': 'US',
    'countryName': 'United States',
    'currencyCode': 'USD',
}

CURRENCY_SYMBOLS = {
    'EUR': '€', 'GBP': '£', 'BRL': 'R$', 'ARS': '$',
    'MXN': '$', 'CLP': '$', 'COP': '$', 'JPY': '¥', 'CNY': '¥', 'INR': '₹',
}


def _subnet_key(ip: str) -> str:
    """Cache at /24 subnet level — reduces unique keys by 250x for office IPs."""
    parts = ip.split('.')
    if len(parts) == 4:
        return '.'.join(parts[:3]) + '.0'
    return ip  # IPv6: use full IP


def get_visitor_geo(ip: str) -> dict:
    """Return country/currency data for an IP. Cached 4h in Django's default cache."""
    # Skip private/loopback IPs
    if ip in ('127.0.0.1', '::1') or ip.startswith(('192.168.', '10.', '172.16.')):
        return dict(_FALLBACK)

    cache_key = 'apogeo:ip:' + _subnet_key(ip)
    cached = cache.get(cache_key)
    if cached is not None:
        return cached

    try:
        resp = requests.get(
            f'{GEO_API_BASE}/ip/{ip}',
            headers={'X-API-Key': settings.APOGEOAPI_KEY},
            timeout=3,
        )
        resp.raise_for_status()
        data = resp.json()
        result = {
            'countryCode': data.get('countryCode', 'US'),
            'countryName': data.get('countryName', 'United States'),
            'currencyCode': data.get('currencyCode', 'USD'),
        }
        cache.set(cache_key, result, timeout=4 * 3600)  # 4 hours
        return result
    except requests.RequestException as exc:
        logger.warning('GeoService failed for %s: %s', ip, exc)
        return dict(_FALLBACK)


def get_exchange_rate(currency: str) -> float:
    """Return USD→currency rate. Cached 4h. Returns 1.0 on failure."""
    if currency == 'USD':
        return 1.0

    cache_key = f'apogeo:fx:{currency}'
    cached = cache.get(cache_key)
    if cached is not None:
        return cached

    try:
        resp = requests.get(
            f'{GEO_API_BASE}/currencies/{currency}/rate',
            headers={'X-API-Key': settings.APOGEOAPI_KEY},
            timeout=3,
        )
        resp.raise_for_status()
        rate = float(resp.json().get('usdRate', 1.0))
        cache.set(cache_key, rate, timeout=4 * 3600)
        return rate
    except (requests.RequestException, ValueError) as exc:
        logger.warning('FX rate fetch failed for %s: %s', currency, exc)
        return 1.0


def localize_price(usd_amount: float, currency_code: str) -> dict:
    """Convert a USD price to local currency."""
    rate = get_exchange_rate(currency_code)
    local = round(usd_amount * rate, 2)
    symbol = CURRENCY_SYMBOLS.get(currency_code, currency_code + ' ')
    return {
        'amount': local,
        'currency': currency_code,
        'symbol': symbol,
        'formatted': f'{symbol}{local:,.2f}',
    }

Django Middleware

# geo/middleware.py
from geo.services import get_visitor_geo


class GeoDetectionMiddleware:
    """
    Inject visitor country/currency into every request.
    Access via request.visitor_geo in views and request.visitor_geo.countryCode in templates.
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        ip = self._get_ip(request)
        request.visitor_geo = get_visitor_geo(ip)
        response = self.get_response(request)
        return response

    def _get_ip(self, request) -> str:
        # Cloudflare → load balancer → direct connection
        cf_ip = request.META.get('HTTP_CF_CONNECTING_IP')
        if cf_ip:
            return cf_ip.strip()

        forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
        if forwarded:
            return forwarded.split(',')[0].strip()

        return request.META.get('REMOTE_ADDR', '127.0.0.1')

Register in settings.py:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    # ... other middleware ...
    'geo.middleware.GeoDetectionMiddleware',   # ← add this
]

Template Tag

# geo/templatetags/geo_tags.py
from django import template
from geo.services import localize_price

register = template.Library()


@register.filter
def to_local_currency(usd_amount, currency_code):
    """{{ 29 | to_local_currency:visitor_currency }}"""
    return localize_price(float(usd_amount), currency_code)['formatted']


@register.inclusion_tag('geo/price_tag.html', takes_context=True)
def local_price(context, usd_amount):
    """{% local_price 29 %} — renders price in visitor's currency."""
    geo = context.get('request').visitor_geo if hasattr(context.get('request', object()), 'visitor_geo') else {}
    currency = geo.get('currencyCode', 'USD')
    return {'price': localize_price(usd_amount, currency)}
<!-- geo/templates/geo/price_tag.html -->
<span class="price">
    {{ price.formatted }}
    <small class="text-gray-400">{{ price.currency }}</small>
</span>

View Usage

# views.py
from django.shortcuts import render
from geo.services import localize_price


def pricing_view(request):
    geo = getattr(request, 'visitor_geo', {'countryCode': 'US', 'currencyCode': 'USD'})
    currency = geo['currencyCode']

    plans = [
        {'name': 'Basic',        'usd': 19},
        {'name': 'Starter',      'usd': 29},
        {'name': 'Professional', 'usd': 79},
    ]

    for plan in plans:
        plan['local'] = localize_price(plan['usd'], currency)

    return render(request, 'pricing.html', {
        'plans': plans,
        'country': geo['countryCode'],
        'currency': currency,
    })
<!-- templates/pricing.html -->
{% load geo_tags %}

<p class="text-sm text-gray-400">Showing prices for {{ country }} in {{ currency }}</p>

{% for plan in plans %}
<div class="plan-card">
    <h3>{{ plan.name }}</h3>
    <p class="price">{{ plan.local.formatted }}</p>
    <p class="usd-note">USD ${{ plan.usd }}</p>
</div>
{% endfor %}

Django REST Framework — API View

For DRF-based projects, inject geo into the serializer context:

# api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from geo.services import localize_price


class PricingAPIView(APIView):
    def get(self, request):
        geo = getattr(request, 'visitor_geo', {'currencyCode': 'USD', 'countryCode': 'US'})
        plans = [
            {'name': 'Basic',        'usd': 19},
            {'name': 'Starter',      'usd': 29},
            {'name': 'Professional', 'usd': 79},
        ]
        for plan in plans:
            plan['local'] = localize_price(plan['usd'], geo['currencyCode'])
        return Response({'country': geo['countryCode'], 'currency': geo['currencyCode'], 'plans': plans})

Async Support (Django 4+ with ASGI)

If you're running Django on ASGI with async views, use httpx.AsyncClient:

# geo/async_services.py
import httpx
from django.conf import settings
from django.core.cache import cache

async def get_visitor_geo_async(ip: str) -> dict:
    cache_key = 'apogeo:ip:' + ip.rsplit('.', 1)[0] + '.0'
    cached = cache.get(cache_key)
    if cached:
        return cached

    async with httpx.AsyncClient(timeout=3) as client:
        try:
            resp = await client.get(
                f'https://api.apogeoapi.com/v1/ip/{ip}',
                headers={'X-API-Key': settings.APOGEOAPI_KEY},
            )
            data = resp.json()
            result = {'countryCode': data.get('countryCode', 'US'), 'currencyCode': data.get('currencyCode', 'USD')}
            cache.set(cache_key, result, 4 * 3600)
            return result
        except httpx.RequestError:
            return {'countryCode': 'US', 'currencyCode': 'USD'}

Cache Backend

The examples above use Django's default cache. For production, switch to Redis:

# settings.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/1'),
    }
}

Summary

  • GeoService: one module for IP lookup + FX rates, with /24 subnet caching
  • GeoDetectionMiddleware: auto-injects request.visitor_geo on every request
  • Template tag: {% local_price 29 %} converts USD to visitor currency in templates
  • DRF: same service works in class-based API views
  • ASGI: async version available with httpx

API docs: api.apogeoapi.com/api/docs — Free tier (1,000 req/month) + 14-day full trial at app.apogeoapi.com/register, no credit card required.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key