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.
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.
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 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.
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.
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.
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.
The SRID decision and the geography/geometry decision are really one decision, and it is worth making deliberately per column rather than by habit. There are three realistic options:
| Column type | Distance units | Strengths | Weaknesses |
|---|---|---|---|
geography=True (SRID 4326) | Meters, on the spheroid | Correct anywhere on Earth; D(km=5) just works; no projection to choose | Fewer functions available; spheroid math is slower; some functions work through internal casts to geometry |
| Geometry, SRID 4326 | Degrees | Every PostGIS function works; ideal for containment tests (contains, intersects) that do not measure anything | Distances and areas come out in degrees, which are meaningless for measurement — a degree of longitude shrinks towards the poles |
| Geometry, projected SRID (a UTM zone or national grid) | Meters, on a plane | Fast Cartesian math with accurate measurements inside the projection's area | Only accurate for a limited region; data outside it is distorted; you must choose the right projection |
A pragmatic pattern for a regional application is to store in 4326 and transform to a local projected system inside the query when you need a measurement, using the Transform database function. For a global product, geography columns for points you measure between, plus 4326 geometry for polygons you only test containment against, is a sound default.
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.
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 a Distance annotation compiles to ST_Distance, which is computed for every candidate row, so always narrow the set first with an indexed filter such as dwithin; that keeps finding the closest twenty out of millions fast. (PostGIS's index-assisted <-> nearest-neighbour operator needs raw SQL.) This single pattern powers store locators, "drivers near you", and proximity search across countless apps.
Two lookups look interchangeable but compile very differently. distance_lte is implemented as a comparison against ST_Distance(...), a computed value, which cannot use the spatial index on its own. dwithin compiles to ST_DWithin, which performs an index-assisted bounding-box pre-filter before the exact test. On a large table this is the difference between an index scan and computing a distance for every row, so prefer dwithin for the filter and keep Distance for the annotation you display and order by.
nearby = (Store.objects
.filter(location__dwithin=(pt, D(km=5)))
.annotate(dist=Distance("location", pt))
.order_by("dist")[:20])
for store in nearby:
print(store.name, round(store.dist.km, 2)) # dist is a Distance object
Note that D objects are only accepted by dwithin on geography columns or projected geometry columns; on a geometry column in SRID 4326 Django requires a numeric value in degrees and raises an error rather than guessing. That error is a useful guard — it is telling you the column type cannot measure meters.
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.
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.
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.
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 simplest map backend that scales reasonably is a view that returns only the features inside the current viewport. The map library sends its bounds on every pan and zoom; the view filters with an indexed bounding-box test and serializes with Django's built-in geojson serializer.
from django.contrib.gis.geos import Polygon
from django.core.serializers import serialize
from django.http import HttpResponse, HttpResponseBadRequest
def zones_geojson(request):
try:
west, south, east, north = map(float, request.GET["bbox"].split(","))
except (KeyError, ValueError):
return HttpResponseBadRequest("bbox=west,south,east,north required")
bbox = Polygon.from_bbox((west, south, east, north))
bbox.srid = 4326
qs = DeliveryZone.objects.filter(area__bboverlaps=bbox)[:500]
data = serialize("geojson", qs, geometry_field="area", fields=["name"])
return HttpResponse(data, content_type="application/geo+json")
Once features number in the tens of thousands, move to vector tiles. PostGIS 3 can build Mapbox Vector Tiles itself with ST_TileEnvelope, ST_AsMVTGeom and ST_AsMVT, so a Django view only has to run one parameterized query per tile and return the bytes.
from django.db import connection
from django.http import HttpResponse
TILE_SQL = """
WITH bounds AS (SELECT ST_TileEnvelope(%s, %s, %s) AS geom),
mvtgeom AS (
SELECT z.id, z.name,
ST_AsMVTGeom(ST_Transform(z.area, 3857), bounds.geom) AS geom
FROM zones_deliveryzone z, bounds
WHERE z.area && ST_Transform(bounds.geom, 4326)
)
SELECT ST_AsMVT(mvtgeom.*, 'zones') FROM mvtgeom;
"""
def zone_tile(request, z, x, y):
with connection.cursor() as cur:
cur.execute(TILE_SQL, [z, x, y])
tile = cur.fetchone()[0]
response = HttpResponse(bytes(tile or b""),
content_type="application/vnd.mapbox-vector-tile")
response["Cache-Control"] = "public, max-age=3600"
return response
The && bounding-box operator lets the GiST index pick candidate rows, and the tile envelope is transformed to the storage SRID rather than transforming every stored geometry for the filter. Tiles are ideal for a CDN or reverse-proxy cache because the URL fully identifies the content; version the tile URLs or purge the cache when zones change. For very large or mostly static datasets, pre-generating tiles offline and serving them as static files removes the database from the request path entirely.
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.
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.
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.
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.
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.
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.
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.
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import MultiPolygon, Point, Polygon
from django.contrib.gis.measure import D
from django.test import TestCase
class SpatialQueryTests(TestCase):
@classmethod
def setUpTestData(cls):
square = Polygon.from_bbox((0, 0, 1, 1))
DeliveryZone.objects.create(
name="unit square", area=MultiPolygon(square, srid=4326))
Store.objects.create(name="origin", location=Point(0, 0, srid=4326))
Store.objects.create(name="east", location=Point(1, 0, srid=4326))
def test_containment(self):
inside = Point(0.5, 0.5, srid=4326)
outside = Point(1.5, 0.5, srid=4326)
self.assertTrue(DeliveryZone.objects.filter(area__contains=inside).exists())
self.assertFalse(DeliveryZone.objects.filter(area__contains=outside).exists())
def test_one_degree_at_equator_is_about_111_km(self):
store = (Store.objects.filter(name="east")
.annotate(dist=Distance("location", Point(0, 0, srid=4326)))
.get())
self.assertAlmostEqual(store.dist.m, 111_319, delta=100)
def test_radius_filter(self):
names = set(Store.objects.filter(
location__dwithin=(Point(0, 0, srid=4326), D(km=100))
).values_list("name", flat=True))
self.assertEqual(names, {"origin"})
The equator test is deliberately chosen: one degree of longitude on the WGS84 spheroid at the equator is about 111.3 km, a number you can check by hand. If someone later changes the column to plain 4326 geometry, this test fails loudly with a distance near 1 instead of quietly shipping degrees to users.
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.
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.
To see the pieces work together, consider a delivery platform that must, for every incoming order, find the zone that serves the drop-off address, pick the three nearest available couriers, and report daily volume per zone. Each step maps onto a technique covered above.
from django.contrib.gis.db import models
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.measure import D
from django.db.models import Count, Q
class Courier(models.Model):
name = models.CharField(max_length=100)
available = models.BooleanField(default=True)
position = models.PointField(geography=True)
class Order(models.Model):
dropoff = models.PointField(srid=4326)
zone = models.ForeignKey("DeliveryZone", null=True,
on_delete=models.SET_NULL)
created = models.DateTimeField(auto_now_add=True)
def assign(order):
order.zone = (DeliveryZone.objects
.filter(area__contains=order.dropoff).first())
order.save(update_fields=["zone"])
if order.zone is None:
return [] # outside every zone: reject or queue for review
return list(Courier.objects
.filter(available=True, position__dwithin=(order.dropoff, D(km=3)))
.annotate(dist=Distance("position", order.dropoff))
.order_by("dist")[:3])
def daily_volume(day):
return (DeliveryZone.objects
.annotate(orders=Count("order", filter=Q(order__created__date=day)))
.order_by("-orders"))
Several design decisions are embedded here. The zone is resolved once and stored as a foreign key, so the reporting query is an ordinary join and count rather than a spatial join repeated on every dashboard load. The drop-off is plain 4326 geometry because it is only ever tested for containment, while courier positions are geography because they are measured against. The courier search is bounded by a radius, so it uses the index through ST_DWithin and never considers couriers in another city. And the "outside every zone" case is explicit: in production, a surprising share of addresses fall in gaps between zones or on their shared borders, and deciding what happens then is a product question, not a spatial one.
Spatial bugs rarely announce themselves. This table maps the symptoms you will actually see to their usual cause.
| Symptom | Likely cause | Fix |
|---|---|---|
Import of django.contrib.gis fails, or errors say the GDAL or GEOS library cannot be found | Native libraries missing or not on the loader path | Install them in the image; set GDAL_LIBRARY_PATH/GEOS_LIBRARY_PATH if needed |
function st_... does not exist | Extension not created in this database, or installed in a schema not on search_path | Create the extension in the right database; check with \dx in psql |
| Distances like 0.045 where you expected 5000 | Geometry column in SRID 4326 measuring degrees | Use a geography column or transform to a projected SRID before measuring |
| Points appear in the ocean or the wrong hemisphere | Latitude and longitude swapped | Construct Point(lng, lat); add a bounding-box sanity test |
| Spatial aggregate or union errors on one dataset only | An invalid geometry somewhere in the input | Find it with an ST_IsValid filter and repair with ST_MakeValid |
| Proximity query slow despite an index | Filter built on ST_Distance, stale statistics, or a function wrapping the indexed column | Use dwithin, run ANALYZE, avoid transforming the column inside the filter |
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.