Nearby search with PostGIS and GeoDjango: let the database do the geometry
· 4 min read
IceCreamGo connects customers with nearby ice cream trucks. The query behind its main screen is "which active trucks are within a few kilometres of me, nearest first?", and it runs every time someone opens the app or moves the map. Get it wrong and the core feature is slow.
The tempting version
The first version you'd write stores latitude and longitude as two floats, loads every active truck, and computes Haversine distance in Python:
trucks = [t for t in Truck.objects.filter(status="ACTIVE")
if haversine(user_lat, user_lng, t.lat, t.lng) <= radius_km]
It works with twenty trucks. But it reads every row on every request, the cost grows with the fleet rather than with the answer, and pagination is wrong because filtering happens after the database has already returned the rows.
The database can do this itself, with an index.
Make location a real spatial column
With PostGIS and GeoDjango, a location is a PointField:
from django.contrib.gis.db import models
class IceCreamTruck(models.Model):
status = models.CharField(max_length=10)
# Denormalized: the latest position, kept on the truck row so the
# nearby query stays a single indexed lookup.
current_location = models.PointField(geography=True, null=True)
class TruckLocation(models.Model):
truck = models.ForeignKey(IceCreamTruck, on_delete=models.CASCADE)
point = models.PointField(geography=True)
recorded_at = models.DateTimeField(auto_now_add=True)
Two decisions are worth explaining:
- The current position is denormalized onto the truck. The history table is for tracking and replay. The nearby query only ever needs the latest point, and putting it on the truck row avoids a "latest location per truck" subquery on the hottest path.
- GeoDjango creates a spatial (GiST) index by default (
spatial_index=True). That index is what lets Postgres skip trucks that are nowhere near you.
The query
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
def nearby_trucks(lat: float, lng: float, radius_km: float):
here = Point(lng, lat, srid=4326) # x = longitude, y = latitude
return (
IceCreamTruck.objects
.filter(status="ACTIVE", current_location__dwithin=(here, D(km=radius_km)))
.annotate(distance=Distance("current_location", here))
.order_by("distance")
)
dwithin uses the index to throw out far-away trucks, Distance computes the exact distance for the ones left, and ordering and pagination happen in SQL. Serialize truck.distance.km and the client gets "1.2 km away" for free.
Three gotchas
1. Point takes longitude first. Point(x, y) means Point(lng, lat). Swap them and every truck in Mumbai appears to be in the Indian Ocean, or the query quietly returns nothing.
2. Geography or geometry decides your units. With a plain geometry column in SRID 4326, distances are in degrees, and Django refuses D(km=…) in a dwithin lookup on it. A degree of longitude is also a different length at different latitudes. geography=True makes PostGIS work in metres on the spheroid, which is what "within 3 km" actually means. For city-scale search, the extra precision cost is negligible.
3. Your test database needs PostGIS too. SQLite won't run these lookups. Run tests against the same postgis/postgis image you deploy with, so the spatial SQL is actually exercised.
Writing locations without melting the database
Reads are only half of it. Drivers report their position while they're on a route:
- The app posts a location every few seconds, only while the route is active. There are no GPS calls when the truck is paused or offline.
- The backend updates
current_locationfor search, appends aTruckLocationrow for history, and publishes to a Django Channels group so customers watching that truck see it move without polling. - A Celery beat task prunes old history rows, so the table doesn't grow forever.
WebSockets are used only for the three things that genuinely need push: truck location, order status and new-order alerts. Menus and order history are plain REST with client-side caching. A menu that changes twice a day doesn't need a socket.
Takeaways
- If a query is "near me", store a spatial type and let PostGIS do the work.
dwithinplusDistanceplus the default GiST index covers most location features. - Use
geography=Truewhen users think in kilometres. - Keep the hot path to one indexed lookup. Denormalize the latest position and keep history separate.
- Real-time updates are a separate concern. Push them only for the screens that need them.