Django Advanced

GeoDjango and PostGIS: Spatial Models, Distance Queries, and Serving Maps

Turn Django into a spatial framework with GeoDjango and PostGIS: geometry fields, SRIDs, spatial lookups, nearest-neighbor distance queries, importing geographic datasets, GiST indexing, and serving maps — plus the coordinate-system pitfalls to avoid.

DjangoZen Team Jul 11, 2026 16 min read 5 views

The moment your app answers "what's near me?", "which delivery zone is this address in?", or "draw these routes on a map", plain latitude/longitude columns stop being enough. GeoDjango turns Django into a full spatial framework on top of PostGIS, giving you geometry fields, spatial queries, and distance math that runs in the database. This tutorial covers setting it up, modelling geographic data, querying it efficiently, importing real datasets, and the coordinate-system pitfalls that trip up everyone the first time.

Why spatial data needs more than two float columns

You can store a latitude and longitude in two FloatFields, and for pinning a single marker that is fine. It falls apart the instant you need to reason about geography: finding the twenty nearest stores, testing whether a point falls inside a delivery polygon, measuring the real distance between two coordinates on a curved Earth, or clipping one region against another. Doing that in Python means pulling every row and computing in application code — slow, wrong at scale, and impossible to index. A spatial database does the math where the data lives, with indexes built for it. GeoDjango is the bridge that lets the ORM express those queries.

PostGIS: the spatial database

PostGIS is the extension that makes PostgreSQL spatial. It adds geometry and geography types, hundreds of spatial functions, and the GiST index that makes spatial queries fast. GeoDjango officially supports other backends, but PostGIS is by far the most capable and the one you should choose for anything serious. Enabling it is a single migration operation.

from django.contrib.postgres.operations import CreateExtension

class Migration(migrations.Migration):
    operations = [CreateExtension("postgis")]

With the extension enabled, your database now understands points, lines, and polygons as first-class values, not opaque blobs.

Setting up GeoDjango

GeoDjango depends on native geospatial libraries — GDAL, GEOS, and PROJ — which must be installed on the host, not just via pip. On Debian that is apt install gdal-bin libgdal-dev; on macOS, Homebrew. Then add the app and switch your database engine.

INSTALLED_APPS += ["django.contrib.gis"]

DATABASES = {
    "default": {
        "ENGINE": "django.contrib.gis.db.backends.postgis",
        "NAME": "myapp", "USER": "myapp", "HOST": "localhost",
    }
}

The most common setup failure is a missing or mismatched GDAL — GeoDjango imports fine but errors at runtime. Verify with python -c "from django.contrib.gis.gdal import GDAL_VERSION; print(GDAL_VERSION)" before going further.

Geometry fields

GeoDjango models use spatial field types instead of floats. A store's location is a PointField; a delivery area is a PolygonField; a route is a LineStringField. There are multi-variants (MultiPolygonField) for shapes made of several pieces, like a country with islands.

from django.contrib.gis.db import models

class Store(models.Model):
    name = models.CharField(max_length=200)
    location = models.PointField(geography=True)  # lng/lat on a sphere

class DeliveryZone(models.Model):
    name = models.CharField(max_length=200)
    area = models.MultiPolygonField(srid=4326)

Note geography=True on the point: it tells PostGIS to treat coordinates as points on a sphere and return distances in meters, which is almost always what you want for real-world locations. Plain geometry treats coordinates as flat Cartesian values, faster but wrong for global distances.

SRID and coordinate systems

Every geometry carries an SRID — a spatial reference identifier saying what the coordinates mean. SRID 4326 (WGS84) is longitude/latitude in degrees, the GPS standard and what you almost always store. SRID 3857 is the flat "web Mercator" projection that map tiles use for display. Mixing them silently is the number-one GeoDjango bug: a distance query between geometries of different SRIDs either errors or, worse, returns nonsense. Standardize on 4326 for storage, transform to 3857 only for display, and never assume — always set the SRID explicitly.

Spatial lookups and queries

GeoDjango exposes spatial predicates as ordinary queryset lookups. You ask which zone contains a point, or which shapes intersect, in the ORM.

from django.contrib.gis.geos import Point

pt = Point(4.895, 52.370, srid=4326)   # Amsterdam
zone = DeliveryZone.objects.filter(area__contains=pt).first()
overlapping = DeliveryZone.objects.filter(area__intersects=some_polygon)

These translate to PostGIS ST_Contains, ST_Intersects, and friends, executed in the database against a spatial index. The same query in Python would mean loading and testing every zone; here it is one indexed lookup.

Distance queries and nearest-neighbor

The classic "stores near me" query combines a distance filter with distance-ordered results. GeoDjango's Distance and the distance annotation make it readable, and with a geography column the answers come back in meters.

from django.contrib.gis.measure import D
from django.contrib.gis.db.models.functions import Distance

nearby = (Store.objects
    .filter(location__distance_lte=(pt, D(km=5)))
    .annotate(dist=Distance("location", pt))
    .order_by("dist")[:20])

Ordering by distance uses PostGIS's index-assisted nearest-neighbor operator, so finding the closest twenty out of millions stays fast. This single pattern powers store locators, "drivers near you", and proximity search across countless apps.

Spatial indexing

Spatial queries are only fast with a GiST index on the geometry column, and GeoDjango creates one by default for spatial fields — but verify it exists, because an unindexed spatial filter degrades to a full scan that is catastrophically slow at scale. Run EXPLAIN on your hot spatial queries and confirm the index is used; a proximity query that does a sequential scan is the difference between two milliseconds and two seconds.

Spatial functions and aggregation

PostGIS math is available as ORM database functions: compute the Area of a zone, the Centroid of a shape, the Union of several polygons, or Length of a route — all in the database, returning to Python only the result. This keeps heavy geometry work off your application server and lets you build things like "total area covered by all active zones" as a single aggregate rather than a Python loop over thousands of polygons.

Importing geographic data

Real projects start from existing datasets — shapefiles of postal areas, GeoJSON of administrative boundaries. GeoDjango's LayerMapping loads these directly into your models, matching source layer fields to model fields.

from django.contrib.gis.utils import LayerMapping

mapping = {"name": "NAME", "area": "MULTIPOLYGON"}
lm = LayerMapping(DeliveryZone, "zones.shp", mapping, transform=True)
lm.save(strict=True)

The transform=True reprojects source coordinates into your model's SRID automatically — exactly the kind of coordinate-system handling you do not want to get wrong by hand.

Serving maps

To draw geometries in a browser, serialize them to GeoJSON and hand them to a JavaScript map library like Leaflet or MapLibre. GeoDjango's serializer or DRF-GIS produces GeoJSON directly from a queryset. For large datasets, do not ship every geometry to the client — generate vector tiles so the map only loads what is in view at the current zoom, keeping the map responsive even over millions of features.

The GIS admin

GeoDjango ships an admin that renders geometry fields as an interactive map you can draw on — GISModelAdmin gives you a slippy map widget for editing points and polygons by hand, invaluable for content editors managing zones or locations without touching raw coordinates.

Performance and pitfalls

Beyond SRID mismatches, watch the geography vs geometry choice: geography is correct for global distances but slower and supports fewer functions, while geometry is fast but needs a projected SRID for meaningful distances — pick per use case. Confirm the GiST index is actually used with EXPLAIN. Simplify overly detailed polygons before serving them, since a boundary with a hundred thousand vertices is slow to query and huge to transmit. And validate imported geometries — real-world shapefiles are full of self-intersections and invalid rings that break spatial functions until you clean them with ST_MakeValid.

Geocoding and reverse geocoding

Users give you addresses, not coordinates, so you need geocoding — turning "Damrak 1, Amsterdam" into a point — and its reverse, turning a point back into a human address. Postgres does not geocode on its own; you call an external service (Nominatim, Google, Mapbox) and store the resulting Point. The discipline is to geocode once on save and persist the coordinates rather than calling the service on every request, both for speed and because those APIs are rate-limited and metered. Cache aggressively and handle the case where an address does not resolve, which is common with user-entered data.

Working with routes and lines

Beyond points and areas, LineStringField models paths — a delivery route, a hiking trail, a tracked journey. PostGIS computes a line's Length, finds where two lines cross, and simplifies an overly detailed track with ST_Simplify so a GPS trace of ten thousand points becomes a smooth, lightweight line for display. Storing and querying routes as real geometries lets you answer questions like "which routes pass through this area?" with an indexed spatial query instead of decoding coordinate arrays in Python.

Buffers and geofencing

A geofence — "notify me when a driver comes within 500 meters" — is a buffer plus a containment test. ST_Buffer grows a point or line into an area of a given radius, and a spatial filter then tells you what falls inside it. This one pattern underlies proximity alerts, catchment areas, and "is this delivery inside the allowed radius?" checks. On a geography column the radius is in meters and the buffer respects the curved Earth, which is what makes the answer trustworthy for real distances.

Precomputation for spatial performance

Some spatial questions are asked constantly and change rarely — which zone each customer is in, the region a store serves. Rather than recomputing the containment on every request, precompute and denormalize it: store the resolved zone as a foreign key, updated when the point or the zones change. Spatial queries are fast but not free, and turning a repeated ST_Contains into a plain indexed foreign-key lookup is a large win on hot paths. Recompute in a background task when the underlying geometry changes.

Testing spatial code

Spatial tests need a PostGIS-backed test database, so your CI must run Postgres with the extension, not SQLite. Build geometries explicitly in tests with known coordinates and assert on distances and containment you can verify by hand — a point you know is inside a polygon, two cities whose distance you can check. Because spatial bugs are usually silent (a wrong SRID returns plausible-but-wrong numbers), tests that pin exact expected distances are your main defense against a coordinate mistake shipping unnoticed.

Spatial joins

Some of the most useful spatial questions relate two tables by geography rather than a foreign key — which sales territory each customer falls in, how many incidents happened inside each district, which stores sit within each delivery zone. This is a spatial join: instead of matching on an id, you match on a spatial relationship like containment or proximity. PostGIS evaluates it with the spatial index on both sides, so joining a hundred thousand points against a few hundred polygons stays fast. Expressed as a Python loop it is an O(n×m) disaster; expressed as a spatial join it is one indexed query, and it is how you turn raw coordinates into aggregates like "revenue per region" without ever storing the region on the row.

Keeping geometries valid and clean

Real geometry data is messy: imported polygons self-intersect, rings wind the wrong way, and coordinates carry more precision than any map needs. Invalid geometries make spatial functions error or return wrong answers, so validate on import with ST_IsValid and repair with ST_MakeValid, and reduce needless precision with ST_SnapToGrid. Treat geometry hygiene as part of your ingest pipeline, not an afterthought — a single invalid polygon can break an aggregate over an entire dataset, and the error message rarely points at the offending row.

When you need it

Reach for GeoDjango when geography is a genuine part of your domain — proximity search, zones and boundaries, routing, mapping, territory logic. If you only ever show a single pin from a stored coordinate, two float columns and a map embed are simpler. But the moment you find yourself writing Python loops to test containment or compute distances, you are reimplementing PostGIS badly, and it is time to let the database do what it was built for.