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 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:

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