DEV Community

pk2502
pk2502

Posted on Originally published at paritosh-dev.vercel.app

Nearby search with PostGIS and GeoDjango: let the database do the geometry

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
    )
Enter fullscreen mode Exit fullscreen mode

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_location for search, appends a TruckLocation row 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. dwithin plus Distance plus the default GiST index covers most location features.
  • Use geography=True when 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.

Originally published on my site. I'm Paritosh, a lead full-stack engineer; more of my work is at paritosh-dev.vercel.app.

Top comments (0)