IP Geolocation in Django: Country Detection, Currency, and Middleware
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_geoon 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