From mapping ride-sharing platforms to showing local storefronts, localized geospatial searches are a massive part of modern application ecosystems. If you are building a service directory (like finding the nearest auto repair shops to a vehicle breakdown), implementing standard relational bounds check is incredibly slow and math-heavy.
To compile high-performance location queries at scale, we must leverage dedicated GIS databases. In this post, we'll explore how to design a dynamic 'Nearby Shop' API using GeoDjango, PostgreSQL, and PostGIS.
The Architecture: Why PostGIS?
Standard databases treat coordinates (latitude/longitude) as generic floats. Doing radius checks requires executing complex trigonometric math (like the Haversine formula) in SQL query blocks. This is CPU-intensive and cannot utilize database indexes.
PostGIS extends PostgreSQL by introducing native geographic objects (like Points and Polygons) and custom R-tree spatial indexes (GIST), turning slow spatial trigonometry into sub-millisecond indexed database searches.
Step 1: Designing the Spatial Database Models
To enable spatial queries, we use GeoDjango’s GIS database wrapper and declare a PointField on our model. The srid=4326 signifies that we are using the WGS-84 coordinate system (standard GPS latitude and longitude):
from django.contrib.gis.db import models
class Provider(models.Model):
name = models.CharField(max_length=255)
is_public = models.BooleanField(default=True)
approved = models.BooleanField(default=True)
class Place(models.Model):
provider = models.ForeignKey(Provider, on_delete=models.CASCADE, related_name='places')
name = models.CharField(max_length=255)
address = models.CharField(max_length=255)
# Geographic location coordinate (longitude, latitude)
location = models.PointField(srid=4326, db_index=True)
Step 2: Building the Geospatial ViewSet
Now, let's write a rest-framework action endpoint that accepts latitude, longitude, and search radius. It will filter shops within the radius, calculate exact distances dynamically, and sort results by proximity:
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from django.contrib.gis.db.models.functions import Distance
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
class ProviderViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Provider.objects.all()
@action(detail=False, methods=["get"], url_path="nearby")
def nearby(self, request):
lat = request.query_params.get("lat")
lng = request.query_params.get("lng")
radius = request.query_params.get("radius", "50") # Default 50 miles
unit = request.query_params.get("unit", "mi").lower()
if not lat or not lng:
return Response({"error": "Latitude (lat) and longitude (lng) are required"}, status=400)
try:
lat_f, lng_f = float(lat), float(lng)
radius_f = float(radius)
except ValueError:
return Response({"error": "Coordinates and radius must be valid numbers"}, status=400)
# Create PostGIS point (longitude, latitude)
reference_point = Point(lng_f, lat_f, srid=4326)
# Define distance criteria
distance_query = D(mi=radius_f) if unit == "mi" else D(km=radius_f)
# High-performance spatial query
queryset = Provider.objects.filter(
approved=True,
is_public=True,
places__location__distance_lte=(reference_point, distance_query)
).annotate(
# Calculate and append proximity distance to response
distance=Distance("places__location", reference_point)
).order_by("distance")
# Serialize and return results
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
Step 3: Geocoding Addresses on the Fly
To let users search by typing free-form addresses (e.g. "Times Square, NY") instead of raw GPS coordinates, we can integrate a Google Maps Geocoding utility that maps text addresses to coordinates before executing the PostGIS search:
import requests
from django.conf import settings
def geocode_address(address_str: str) -> dict:
"""
Converts a text address to GPS coordinates using Google Geocoding API.
"""
url = "https://maps.googleapis.com/maps/api/geocode/json"
params = {"address": address_str, "key": settings.GOOGLE_MAPS_API_KEY}
response = requests.get(url, params=params, timeout=10)
if response.status_code != 200:
raise Exception("Geocoding service unavailable")
data = response.json()
if data.get("status") != "OK":
raise Exception(f"Geocoding failed: {data.get('status')}")
location = data["results"][0]["geometry"]["location"]
return {"lat": location["lat"], "lng": location["lng"]}
Key Geospatial Performance Takeaways
- Verify Spatial Indexes: Always add
db_index=Trueon yourPointFieldto tell PostGIS to compile spatial GIST indexes, speeding up queries by up to 100x. - Longitude First: Note that standard GeoDjango
Pointobjects expect parameters in(longitude, latitude)format—not the standard Google Maps(latitude, longitude)format. Reversing them is a classic bug that maps coordinates to incorrect hemispheres! - Unit conversions: Utilize GeoDjango's built-in
D(Distance) wrapper. It handles miles, kilometers, meters, and nautical miles natively, bypassing manual math validation.
By shifting spatial calculations to PostGIS and GeoDjango, you can design localized geographic search APIs that are blazing fast, robust, and highly scalable.