GeoDjango
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer with the PostGIS 3.2-3.6 extension, all checked on 2026-10-03; where the 5.2 LTS
(5.2.17) behaves differently the text says so. GeoDjango is the part of Django that lives in django.contrib.gis: model
fields for points, lines, polygons and rasters, a spatial lookup and function layer on top of the ORM, Python wrappers
for the GEOS and GDAL libraries, form widgets and an admin that edit geometries on a map, and a few import and
serialization tools. No PostgreSQL or PostGIS server was available while writing, so nothing on this page that needs
a spatial database was executed against one; the GEOS and GDAL parts were executed for real, and the database-facing
examples were compiled to SQL with the genuine PostGIS backend classes. The section What was run, and what was not lists, example by
example, what was run and what was not. In the Bookshelf application that grows across this section, GeoDjango locates
bookshops and book-club meetups, answers "which meetups are within five kilometres of me", measures courier routes
and lets the staff drag a shop’s pin on a map in the admin.
In a hurry: what goes where
| Need | Use | Section |
|---|---|---|
Which database |
PostgreSQL 15+ with PostGIS (the recommended and most complete backend); SQLite with SpatiaLite, MySQL/MariaDB and Oracle have fewer features. |
|
Install it |
Install GEOS, GDAL and PROJ, create the PostGIS extension, add |
|
A column that holds a place |
|
|
Distances in metres on WGS 84 |
|
|
Work with a shape in Python |
|
|
Read a shapefile or GeoJSON file |
|
The GDAL API: reading data files, Importing data: |
Find rows by position |
Spatial lookups such as |
|
Compute with geometries in SQL |
|
|
Units |
|
|
Edit a shape in the admin |
|
|
Send shapes to a map in a browser |
The |
|
Stay safe with user input |
Validate coordinates, never pass raw strings or dictionaries into spatial lookups, keep the 2026 security fixes. |
What was run, and what was not
Every claim about behaviour comes from the Django 6.1 documentation and the Django 6.1.1 source of the installed
package. The examples were exercised in a scratch project with Django 6.1.1, Python 3.13, psycopg 3.3.6 and, because
the machine had no system geospatial libraries, GEOS 3.13.1 and GDAL 3.12.2 (with their bundled PROJ data) taken from
the shapely and rasterio wheels and handed to Django through GEOS_LIBRARY_PATH and GDAL_LIBRARY_PATH. Both
versions are inside the ranges Django 6.1 supports (see Library versions Django 6.1 supports). There was no PostGIS server and no SpatiaLite
(mod_spatialite), so no spatial query returned rows. The honest summary:
| Example | Needs a spatial database | What was done |
|---|---|---|
GEOS geometries: constructors from WKT/HEX/WKB/GeoJSON, |
no |
Executed with real GEOS and PROJ; the printed values below are the observed ones. |
|
no |
Executed with real GDAL on a temporary GeoJSON file. |
|
no |
Executed. |
The |
the queryset |
The serializer ran on unsaved model instances. The view ran with |
Forms with |
no |
Executed: validation results, the widget classes and their attributes were checked in tests. |
Models with spatial fields, |
yes, to create the tables |
|
Spatial lookups, distance lookups, functions and aggregates |
yes, to return rows |
Compiled to SQL with |
|
yes, or a data file |
Imports and signatures checked only; copied from the documentation otherwise. Not run. |
Installation steps, |
yes |
Not run; taken from the documentation (the image name and tags were looked up on the registry, nothing was pulled). |
The scratch settings and the stubs live only in the scratch project: they replace the PostGIS wrapper’s connection with
a mock, fix the server version and read SRID units from PROJ. The stubs change nothing about how Django builds SQL,
which is what the quoted fragments show. To reproduce real results, start PostGIS (see Installing on PostgreSQL with PostGIS) and run the tests in
Testing GeoDjango code with DJANGO_SETTINGS_MODULE pointing at PostGIS settings.
What GeoDjango is
GeoDjango is not a separate package: it is django.contrib.gis, installed with Django. It adds, in layers:
-
Model fields for the OGC Simple Features geometry types and for rasters, with a spatial index by default.
-
ORM extensions: spatial lookups such as
intersectsanddwithin, geographic database functions such asDistanceandTransform, and GIS aggregates such asExtentandUnion. -
Python interfaces to GEOS (
django.contrib.gis.geos, geometry operations without a database) and GDAL (django.contrib.gis.gdal, reading vector and raster data), plus measurement objects (django.contrib.gis.measure). -
Forms and admin: map widgets built on OpenLayers and
GISModelAdmin. -
Tools:
LayerMapping, theogrinspectcommand, a GeoJSON serializer, geographic feeds and KML sitemaps, GeoIP2.
fields, lookups, functions, widgets"] B --> C["GEOS API
geometry in Python"] B --> D["GDAL API
vector and raster files"] B --> E["Spatial backend
postgis, spatialite, mysql, oracle"] E --> F["PostGIS in PostgreSQL
GEOS, PROJ, GDAL"] C -.-> G["libgeos_c"] D -.-> H["libgdal, PROJ"]
Source: GeoDjango
The GEOS and GDAL interfaces work without a database or a settings module (the documentation says they can be used independently of Django), which is how most of the examples on this page were run. Geometries are lazy: when a
model instance is loaded the column is kept in a textual form and the GEOSGeometry object is created the first time
you access the attribute, which is why shop.location gives you a rich object with wkt, geojson, contains() and
dozens of other members.
Supported databases
| Database | Needs | Version | Notes |
|---|---|---|---|
PostgreSQL |
GEOS, GDAL, PROJ, PostGIS |
15+ |
Recommended as the most mature and feature-rich open source spatial database. Only backend with |
MySQL |
GEOS, GDAL |
8.4+ |
Limited functionality: spatial functions work on real geometries but are less rich. |
Oracle |
GEOS, GDAL |
19+ |
XE not supported. |
SQLite |
GEOS, GDAL, PROJ, SpatiaLite |
3.37.0+ |
Requires SpatiaLite 4.3+. Cannot do distance calculations between geographic (WGS 84) geometries in the way PostGIS can. |
Source: GeoDjango installation
The ENGINE setting selects the spatial backend: django.contrib.gis.db.backends.postgis, .mysql, .oracle or
.spatialite. MariaDB uses the mysql backend. Everything on this page uses PostGIS unless it says otherwise; the
documented compatibility tables for lookups, functions and aggregates are in
GeoDjango Database API. If you call a function the
backend does not support, Django raises NotImplementedError.
Library versions Django 6.1 supports
| Library | Supported versions | Required for |
|---|---|---|
GEOS |
3.14, 3.13, 3.12, 3.11, 3.10 |
every backend |
PROJ |
9.x, 8.x, 7.x, 6.x |
PostgreSQL and SQLite |
GDAL |
3.13 down to 3.3 |
every backend |
PostGIS |
3.6, 3.5, 3.4, 3.3, 3.2 |
PostgreSQL |
SpatiaLite |
5.1, 5.0, 4.3 |
SQLite |
GeoIP (optional) |
2 |
IP geolocation |
Source: Installing geospatial libraries
Django 6.1 dropped PostGIS 3.1, GEOS 3.8 and 3.9 and GDAL 3.1 and 3.2; the documentation adds that newer or older versions may also work fine.
Installing on PostgreSQL with PostGIS
Installation has four parts: the geospatial libraries, PostGIS in the database server, the database adapter, and Django’s own settings.
The geospatial libraries
GeoDjango locates libgeos_c and libgdal with ctypes.util.find_library(). On Debian and Ubuntu the documentation
suggests this one-liner for the libraries and for the binutils that find_library() needs on Linux:
sudo apt-get install binutils libproj-dev gdal-bin
Source: Installing geospatial libraries
On macOS the documentation lists Postgres.app (the easiest: a PostgreSQL server that already includes PostGIS, plus
gdal from Homebrew), Homebrew (brew install postgresql postgis gdal), Fink and MacPorts. On Windows it uses the
EnterpriseDB PostgreSQL installer with the PostGIS add-on from Stack Builder and the OSGeo4W installer for GDAL, then
sets GDAL_DATA and PROJ_LIB and the PATH. The Python adapter is psycopg (psycopg 3 or psycopg2).
If Django cannot find a library it raises ImportError/ImproperlyConfigured naming the library and telling you to set
GEOS_LIBRARY_PATH or GDAL_LIBRARY_PATH. Both settings take the full path of the C shared library; for GEOS it is
libgeos_c, not libgeos. That is exactly what the scratch project of this page did, because its libraries came from
Python wheels:
import os
# Only needed when the libraries are not where find_library() looks.
if "GEOS_LIBRARY_PATH" in os.environ:
GEOS_LIBRARY_PATH = os.environ["GEOS_LIBRARY_PATH"]
if "GDAL_LIBRARY_PATH" in os.environ:
GDAL_LIBRARY_PATH = os.environ["GDAL_LIBRARY_PATH"]
PostGIS
PostGIS adds geographic object support to PostgreSQL. Install the server package for your platform together with its
PostGIS package (on Debian and Ubuntu the documentation names postgresql-x, postgresql-x-postgis-3 and
postgresql-server-dev-x, with x the PostgreSQL major version). For a development database a container that already
contains both is the quickest route. The postgis/postgis image exists on the registry with tags that pair a PostgreSQL
and a PostGIS version, for example 17-3.5; the password is read from your shell instead of being written down:
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
docker run --name bookshelf-db -e POSTGRES_PASSWORD -e POSTGRES_DB=bookshelf \
-p 127.0.0.1:5432:5432 -d postgis/postgis:17-3.5
Source: Installing PostGIS. The image is documented at postgis/postgis on Docker Hub.
The extension must exist in the database. Django can create it for you: the PostGIS backend’s prepare_database()
checks pg_extension and issues CREATE EXTENSION IF NOT EXISTS postgis when migrate runs (read in the 6.1.1
source), and the documentation says the database user must be a superuser for that. The alternative that keeps the
step in version control, and lets a non-superuser deployment pipeline run it as a privileged step, is a migration
operation:
from django.contrib.postgres.operations import CreateExtension
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
CreateExtension("postgis"),
# CreateModel operations follow
]
Source: Creating a spatial database
Two optional extensions: postgis_raster (needed for RasterField, activated with the same CreateExtension
operation or CREATE EXTENSION postgis_raster;) and postgis_topology, which GeoDjango does not use.
CreateExtension is described on PostgreSQL & Database Backends.
Django settings
import os
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.gis", # templates for the map widgets, the gis inspectdb, KML sitemaps
"places",
]
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": os.environ.get("POSTGRES_DB", "bookshelf"),
"USER": os.environ.get("POSTGRES_USER", "bookshelf"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
}
}
django.contrib.gis must be in INSTALLED_APPS so that the gis templates can be located; without it the map
widgets, the geographic admin and KML sitemaps do not work properly. With it installed, Django also replaces the
inspectdb command by a spatially aware one that writes geometry fields into the generated models. The scratch project
used exactly this layout with the engine swapped in, and manage.py check reported no issues. General settings
handling is on Settings & Configuration.
SpatiaLite, MySQL and Oracle in one paragraph
For SQLite, install SpatiaLite 4.3+ (on Debian-based systems libsqlite3-mod-spatialite; with Homebrew
brew install spatialite-tools and brew install gdal), make sure SQLite was built with the R*Tree module, use the
django.contrib.gis.db.backends.spatialite engine and, when Django cannot find the extension, set
SPATIALITE_LIBRARY_PATH to the mod_spatialite library (the documented Homebrew value is
/opt/homebrew/lib/mod_spatialite.dylib). SpatiaLite is fine for a prototype or a single-user tool; it cannot do
distance calculations between geometries in a geographic coordinate system the way PostGIS can, and rasters are
PostGIS-only. MySQL and Oracle need only GEOS and GDAL on the Python side.
Source: Installing SpatiaLite
The models used on this page
from django.contrib.gis.db import models
from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.measure import D
class Bookshop(models.Model):
name = models.CharField(max_length=100)
# WGS 84 (SRID 4326) is the default; a spatial (GiST) index is created by default.
location = models.PointField()
# A projected, metric SRID for the delivery area (Web Mercator here).
delivery_area = models.PolygonField(srid=3857, null=True, blank=True)
def __str__(self):
return self.name
class MeetupQuerySet(models.QuerySet):
def near(self, point, km):
"""Meetups within ``km`` kilometres of ``point``, nearest first."""
return (
self.filter(venue__dwithin=(point, D(km=km)))
.annotate(distance=Distance("venue", point))
.order_by("distance")
)
class Meetup(models.Model):
objects = MeetupQuerySet.as_manager()
title = models.CharField(max_length=100)
# geography=True: a "geography" column; distances are in metres on a sphere.
venue = models.PointField(geography=True)
def __str__(self):
return self.title
class CourierRoute(models.Model):
name = models.CharField(max_length=100)
path = models.LineStringField()
class Region(models.Model):
name = models.CharField(max_length=100)
boundary = models.MultiPolygonField()
Note the import: from django.contrib.gis.db import models re-exports everything in django.db.models and adds the
spatial fields, so one import is enough for a model that mixes both. See
Managers & Custom QuerySets for the as_manager() pattern.
Geometry fields
| Field | Stores |
|---|---|
|
The base class; any geometry type. |
|
A |
|
A |
|
A |
|
The matching multi-geometry. |
|
A |
|
A |
Source: Spatial field types
Each geometry field corresponds to an OpenGIS Simple Features type; there is no equivalent standard for rasters.
Choose the most general type your data needs: a MultiPolygonField is the safe choice for administrative areas
because countries have islands. Polygon data that arrives in a collection, for example from a shapefile that declares
"Polygon" but contains multipolygons, goes through LayerMapping (see Importing data: LayerMapping and ogrinspect), which converts it.
Field options
All spatial fields accept the usual field options plus:
| Option | Meaning |
|---|---|
|
The spatial reference system identifier. Default 4326 (WGS 84, units are degrees of longitude and latitude). |
|
Default |
|
Coordinate dimension, default 2; |
|
|
|
Geometry fields only. Forwarded to the form field; bounds how many geometry collections may be nested or contained in submitted WKT or WKB. Default 198. Added by the August 2026 security fix (see Security: the 2026 fixes and what to validate). |
Source: Spatial field options
What the migration creates
makemigrations writes the fields from django.contrib.gis.db.models.fields, and sqlmigrate places 0001 on the
PostGIS backend produced this DDL (executed with the server stubs, abridged to the interesting statements):
CREATE TABLE "places_bookshop" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"name" varchar(100) NOT NULL, "location" geometry(POINT,4326) NOT NULL,
"delivery_area" geometry(POLYGON,3857) NULL);
CREATE TABLE "places_meetup" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"title" varchar(100) NOT NULL, "venue" geography(POINT,4326) NOT NULL);
CREATE TABLE "places_region" (..., "boundary" geometry(MULTIPOLYGON,4326) NOT NULL);
CREATE INDEX "places_bookshop_location_e9a71301_id" ON "places_bookshop" USING GIST ("location");
CREATE INDEX "places_meetup_venue_523e762c_id" ON "places_meetup" USING GIST ("venue");
Three things to read from it: the geometry type and SRID are part of the column type (so PostGIS itself rejects a
polygon in a point column), the geography column gets the same GiST index, and every spatial field gets an index
unless you pass spatial_index=False. makemigrations --check --dry-run afterwards reported "No changes detected".
The general migration workflow is on Migrations.
Source: GeoDjango tutorial, run migrate
Choosing an SRID: degrees, projections and geography
The SRID decides how coordinates are read, and therefore what a "distance" means. WGS 84 (4326) stores longitude and latitude, which are angles; one degree is not a fixed number of metres, so a distance between two WGS 84 points is either in degrees (the planar answer) or needs a calculation on the sphere or spheroid. The documentation’s advice has three branches:
-
Projected system for a region (a metric SRID such as a UTM zone or a national grid): coordinates are in metres or feet, planar distance is exact enough and fast, indexes are used. Only valid inside the region it was designed for.
-
Geographic system with distance functions (4326 with
geometrycolumns): Django asks PostGIS forST_DistanceSphereorST_DistanceSpheroid. Correct, but the spatial index on traditional geometry fields cannot be used, so the distance is computed for every row. -
geography=True(PostGIS, forces 4326): distance and measurement operations on a geography column automatically use great circle arc calculations and return linear units, and the index is used. The price is that only a subset of lookups works on geography: besides the distance lookups onlybboverlaps,coveredby,coversandintersects.
When you need a geography column in a lookup or aggregate that does not support it, cast it to a geometry in the query:
from django.contrib.gis.db.models import PointField
from django.db.models.functions import Cast
Meetup.objects.annotate(geom=Cast("venue", PointField())).filter(geom__within=polygon)
The compiled SQL contains ("places_meetup"."venue")::geometry(POINT,4326) AS "geom" and
WHERE ST_Within"places_meetup"."venue")::geometry(POINT,4326), ST_GeomFromEWKB(<ewkb>. When you save a geometry
whose SRID differs from the field’s, Django wraps it in ST_Transform(…, field_srid) for you (see Creating and saving models with geometry fields). The
PostGIS documentation has a section on when to use geography versus geometry; spatialreference.org lists SRIDs.
Source: Selecting an SRID and Geography type
Creating and saving models with geometry fields
A geometry attribute accepts a GEOSGeometry, WKT, hex-encoded EWKB (PostGIS), GeoJSON, and, for assignment only, the
other forms GEOSGeometry understands; anything that is not already a GEOSGeometry is parsed into one:
from django.contrib.gis.geos import GEOSGeometry
from places.models import Bookshop
shop = Bookshop(name="Libros", location="POINT(-3.7038 40.4168)") # WKT
type(shop.location), shop.location.srid
# (<class 'django.contrib.gis.geos.point.Point'>, 4326)
shop.location = GEOSGeometry("SRID=3857;POINT(-412300 4926000)") # EWKT, another SRID
shop.location.srid
# 3857
The geometry keeps its own SRID until it is saved, when it is implicitly transformed into the SRID of the model’s field using the spatial database’s transform procedure. The documentation shows the generated statement as
INSERT … ST_Transform(ST_GeomFromWKB(…, 3084), 4326) (needs DEBUG=True to inspect through
connection.queries). Assigning the wrong geometry type fails early, in Python:
Region(name="x", boundary=Polygon(((0, 0), (0, 1), (1, 1), (0, 0))))
# TypeError: Cannot set Region SpatialProxy (MULTIPOLYGON) with value of type: <class '...Polygon'>
Bookshop(name="x", location=Polygon(((0, 0), (0, 1), (1, 1), (0, 0))))
# TypeError: Cannot set Bookshop SpatialProxy (POINT) with value of type: <class '...Polygon'>
Source: GeoDjango: GeometryField
This matters for the tutorial’s remark that a MultiPolygonField accepts a Polygon: it is true for data
imported through LayerMapping, which promotes the geometry, but a plain assignment of a Polygon to a
MultiPolygonField raises TypeError in Django 6.1.1. Wrap it yourself with MultiPolygon(poly).
Raster fields
RasterField stores raster data (a digital elevation model, for example) and exists only on PostGIS, with the
postgis_raster extension. Lookups work on rasters (see Spatial lookups), but spatial database functions and aggregates are
not implemented for them. The field accepts anything the GDALRaster constructor accepts: a path, a GDALRaster, or a
dictionary describing a raster. This dictionary builds an in-memory raster (executed here with real GDAL):
from django.contrib.gis.gdal import GDALRaster
rast = GDALRaster({
"width": 10, "height": 10, "name": "Canyon", "srid": 4326,
"scale": [0.1, -0.1], "bands": [{"data": range(100)}],
})
rast.srid, rast.width, rast.extent
# (4326, 10, (0.0, -1.0, 1.0, 0.0))
Assigning a dict or a file path to a RasterField opens or fetches that raster, so never assign untrusted input to a
raster field; see Security: the 2026 fixes and what to validate. Saving a raster model was not run here.
The GEOS API: geometries in Python
django.contrib.gis.geos wraps the GEOS C library. Every geometry is a GEOSGeometry; the constructor takes WKT, HEX,
WKB (as a memoryview) or GeoJSON, and the type-specific classes (Point, LineString, LinearRing, Polygon and
the multi types) take coordinates. All accept an srid keyword.
from django.contrib.gis.geos import GEOSGeometry, LineString, Point, Polygon
GEOSGeometry("POINT(5 23)").geom_type
# 'Point'
GEOSGeometry('{ "type": "Point", "coordinates": [ 5.000000, 23.000000 ] }').wkt
# 'POINT (5 23)'
GEOSGeometry("SRID=32140;POINT(954158.1 4215137.1)").srid
# 32140
madrid = Point(-3.7038, 40.4168, srid=4326) # x is longitude, y is latitude
madrid.ewkt
# 'SRID=4326;POINT (-3.7038 40.4168)'
madrid.transform(3857, clone=True).coords
# (-412305.1300001266, 4926696.669635533)
Source: GEOS API
Remember the order: Point(x, y) is longitude, latitude, the reverse of how people say coordinates aloud. Without
clone=True, transform() changes the geometry in place and returns None.
Operating on geometries
Geometries are "Pythonic": coordinates can be indexed, iterated, changed and appended like lists, and they support
set-like operators (| union, & intersection, - difference, ^ symmetric difference). Binary predicates return
booleans, constructive operations return new geometries:
poly = Polygon(((0, 0), (0, 50), (50, 50), (50, 0), (0, 0)), srid=4326)
poly.area, poly.centroid.wkt, poly.extent
# (2500.0, 'POINT (25 25)', (0.0, 0.0, 50.0, 50.0))
poly.contains(Point(10, 10)), Point(10, 10).within(poly)
# (True, True)
poly.buffer(1).geom_type
# 'Polygon'
bowtie = Polygon(((0, 0), (2, 2), (2, 0), (0, 2), (0, 0)))
bowtie.valid, bowtie.valid_reason
# (False, 'Self-intersection[1 1]')
bowtie.make_valid().wkt
# 'MULTIPOLYGON (((0 2, 1 1, 0 0, 0 2)), ((2 0, 1 1, 2 2, 2 0)))'
The reference also covers intersects, covers, crosses, disjoint, overlaps, touches, relate_pattern,
difference, union, simplify, interpolate, project, distance, normalize and the properties coords, wkt,
wkb, hex, ewkt, geojson, json, extent, centroid, envelope and prepared. A PreparedGeometry (the
prepared property) speeds up repeated contains, intersects or covers tests against one geometry; it behaved as
documented in a quick check.
Two comparisons deserve a warning because they trip everyone once. The == operator is equals_exact(), not spatial
equality: the same line drawn in the opposite direction is equals() but not ==.
== (executed)ls1 = LineString((0, 0), (1, 1))
ls2 = LineString((1, 1), (0, 0))
ls1.equals(ls2), ls1 == ls2
# (True, False)
Source: GEOS API tutorial
And geometries should be created inside functions, not at module level: the documentation explains that a module-level
GEOS object can be garbage collected after its module, filling logs with TypeError or AttributeError. The MADRID constant in the test excerpt is created at import time only because it is test scaffolding; in application code build geometries in the function that needs them.
The GDAL API: reading data files
django.contrib.gis.gdal wraps OGR (vector data) and GDAL’s raster support. The objects you meet most are
DataSource (a file or database connection that holds layers), Layer, Feature, OGRGeometry, SpatialReference
and CoordTransform, and GDALRaster. A shapefile has one layer; a GeoJSON file also exposes one. This ran against a
temporary GeoJSON file with two cities:
from django.contrib.gis.gdal import DataSource
ds = DataSource("cities.geojson")
print(ds, len(ds))
# cities.geojson (GeoJSON) 1
layer = ds[0]
layer.name, layer.geom_type, len(layer), layer.fields, layer.srs.srid
# ('cities', Point, 2, ['name', 'pop'], 4326)
[f.__name__ for f in layer.field_types]
# ['OFTString', 'OFTInteger']
for feature in layer:
print(feature.get("name"), feature.geom.wkt, feature.geom.num_points)
# Madrid POINT (-3.7038 40.4168) 1
# Lisbon POINT (-9.1393 38.7223) 1
Source: GeoDjango tutorial, GDAL interface and GDAL API
On the command line the GDAL tool ogrinfo -so file layer prints the same summary (feature count, extent, SRS and
field types) and is the usual first step before writing a model. The GDAL interface is documented as vector-oriented;
curved geometries (CurvePolygon, CompoundCurve and others) are supported since Django 5.2 through
OGRGeometry.has_curve, get_linear_geometry() and get_curve_geometry(), and measured geometries since 5.1.
Importing data: LayerMapping and ogrinspect
Real projects need to load geographic data they did not type. The tools, in order of how much you want to write
yourself: ogrinspect writes the model and the mapping from a file, LayerMapping loads the file into the model, and,
outside Django, ogr2ogr (included with GDAL) and shp2pgsql (included with PostGIS) import many formats directly.
python manage.py ogrinspect cities.geojson City --srid=4326 --mapping --name-field=name
# This is an auto-generated Django model module created by ogrinspect.
from django.contrib.gis.db import models
class City(models.Model):
name = models.CharField(max_length=0)
pop = models.IntegerField()
geom = models.PointField(srid=4326)
def __str__(self): return self.name
# Auto-generated `LayerMapping` dictionary for City model
city_mapping = {
'name': 'name',
'pop': 'pop',
'geom': 'POINT',
}
Look at max_length=0: GeoJSON carries no string widths, so ogrinspect could not know the length and wrote 0.
Always read the generated model and fix such fields by hand. The command’s options are --srid, --mapping,
--multi-geom, --geom-name, --layer, --name-field, --blank, --null, --decimal and --no-imports. The
tutorial spells the multi option --multi; that works because it is an unambiguous abbreviation of --multi-geom
(checked: --multi produced a MultiPolygonField), but the documented name is --multi-geom. The same logic is
available from Python as ogrinspect(data_source, model_name, **kwargs) and mapping(data_source, geom_name='geom',
layer_key=0, multi_geom=False) in django.contrib.gis.utils.
Source: GeoDjango management commands and OGR inspection
Loading is LayerMapping. The mapping dictionary maps model fields to layer field names and the geometry field to a
geometry type name, which is how a plain Polygon in a file becomes a MULTIPOLYGON:
from pathlib import Path
from django.contrib.gis.utils import LayerMapping
from .models import Region
region_mapping = {
"name": "NAME",
"boundary": "MULTIPOLYGON",
}
regions_shp = Path(__file__).resolve().parent / "data" / "regions.shp"
def run(verbose=True):
lm = LayerMapping(Region, regions_shp, region_mapping, transform=False)
lm.save(strict=True, verbose=verbose)
Source: GeoDjango tutorial, LayerMapping
LayerMapping(model, data_source, mapping, layer=0, source_srs=None, encoding=None,
transaction_mode='commit_on_success', transform=True, unique=True, using='default') has these knobs worth knowing:
transform=False inserts geometries unchanged (use it only when the file already is in the field’s SRID),
source_srs for files without a .prj, encoding for legacy shapefiles, unique to collect the geometries of features that share a key into one model instance (it forces autocommit), and using for multiple databases. save() accepts strict (stop at the first error, off by
default), silent, verbose, progress, step (commit every N features) and fid_range. Run it with DEBUG=False:
Django keeps every query in memory under DEBUG=True, which exhausts memory on large imports. Call it from a
management command or a data migration, not from a view.
Source: LayerMapping data import utility
Spatial lookups
Lookups are the spatial equivalent of __icontains: they filter on a geometry or raster field and are available on any
manager method (filter(), exclude(), get()). Normal lookups can be chained with spatial ones.
from django.contrib.gis.geos import Point, Polygon
from places.models import Bookshop, Region
madrid = Point(-3.7038, 40.4168, srid=4326)
area = Polygon(((-3.75, 40.38), (-3.75, 40.45), (-3.65, 40.45), (-3.65, 40.38), (-3.75, 40.38)), srid=4326)
Region.objects.filter(boundary__contains=madrid)
# WHERE ST_Contains("places_region"."boundary", ST_GeomFromEWKB(<ewkb>))
Region.objects.filter(boundary__intersects="POINT(-3.7 40.4)") # WKT is accepted
# WHERE ST_Intersects("places_region"."boundary", ST_GeomFromEWKB(<ewkb>))
Bookshop.objects.filter(location__within=area)
# WHERE ST_Within("places_bookshop"."location", ST_GeomFromEWKB(<ewkb>))
Region.objects.filter(boundary__bbcontains=madrid) # bounding boxes only
# WHERE "places_region"."boundary" ~ ST_GeomFromEWKB(<ewkb>)
Region.objects.filter(boundary__relate=(madrid, "T*T***FF*")) # DE-9IM pattern for "contains"
# WHERE ST_Relate("places_region"."boundary", ST_GeomFromEWKB(<ewkb>), 'T*T***FF*')
Source: Spatial lookups
(In the real SQL the pattern is a quoted parameter; the stubbed str(queryset.query) shows it bare, and Django
documents that this string is not necessarily valid SQL.) The right-hand side may be a GEOSGeometry, WKT, EWKT,
HEXEWKB or GeoJSON; if it is in another SRID Django transforms it, which the compiled SQL shows as
ST_Within("places_bookshop"."location", ST_Transform(ST_GeomFromEWKB(<ewkb>), 4326)). That is the "automatic spatial
transformation" of the tutorial, and it works in your favour: query in the SRID your input arrives in.
The lookups
| Lookup | Tests | PostGIS function |
|---|---|---|
|
the field geometry spatially contains the value |
|
|
the value intersects the interior of the field, but not its boundary or exterior |
|
|
the field is inside the value |
|
|
no point of one lies outside the other (differs from contains/within at boundaries) |
|
|
the geometries share any point |
|
|
they share no point |
|
|
the matching DE-9IM predicate |
|
|
the intersection matrix matches a nine-character pattern; value is |
|
|
"equal"; on PostGIS this compares bounding boxes, elsewhere spatial equality |
the |
|
bounding-box tests only |
the |
|
position of bounding boxes (PostGIS only) |
the box operators |
|
validity, emptiness |
|
|
geometry type name, for example |
|
|
number of dimensions, for example |
|
|
distance comparisons, see Distance queries |
|
Source: Spatial lookups, availability per backend in the compatibility tables
Choosing between them is mostly about boundaries and cost. contains and within are the strict pair; covers and
coveredby treat a point on the boundary as inside, which is what most people mean and, on PostGIS, can be what a
geography column supports (contains is not available on geography). intersects is the general-purpose "touches
in any way" test and the one to reach for first. bbcontains and the other bounding-box lookups are the cheap
pre-filters. Observed in compiled SQL: geom_type="MULTIPOLYGON" becomes GeometryType(col) = 'MULTIPOLYGON',
isvalid=False becomes NOT ST_IsValid(col), num_dimensions=2 becomes ST_NDims(col) = 2.
One trap: exact on PostGIS compares bounding boxes, so Region.objects.filter(boundary=geom) can match a region whose shape differs but whose box is the same. Use equals when you mean the shapes are the same.
Raster lookups
On PostGIS lookups work on rasters on either side, and geometry and raster inputs can be mixed. A band index goes in
the left-hand side with the double underscore or in the right-hand side as a tuple: Elevation.objects.filter(rastcontains=geom),
rast1contains=geom, rastcontains=(rst, 1). Each lookup has native (N), bilateral (B) or geometry-conversion ©
raster support, listed in the compatibility table; where the operator does not accept rasters natively Django converts
the raster to a polygon with ST_Polygon, which you must keep in mind when interpreting results. Raster lookups were
not run here. They were also the subject of a security fix: see Security: the 2026 fixes and what to validate.
Source: Raster lookups
Distance queries
Distance lookups take a tuple: the geometry (or raster) to measure from, and the distance, as a number in the field’s
units, a Distance object, or a query expression. The five lookups are distance_lt, distance_lte, distance_gt,
distance_gte and dwithin (not on MariaDB and MySQL). Distance objects may be in any unit; Django converts to the
field’s units.
from django.contrib.gis.measure import D
from places.models import Bookshop, Meetup
Meetup.objects.filter(venue__distance_lte=(madrid, D(km=5)))
# WHERE ST_Distance("places_meetup"."venue", ST_GeogFromWKB(<ewkb>)) <= 5000.0
Meetup.objects.filter(venue__dwithin=(madrid, D(km=5)))
# WHERE ST_DWithin("places_meetup"."venue", ST_GeomFromEWKB(<ewkb>), 5000.0)
On the geometry column Bookshop.location (WGS 84) the same intent has two spellings, depending on whether you want an
exact distance or an indexed one:
# Exact, but computed per row and unable to use the spatial index: ST_DistanceSphere
Bookshop.objects.filter(location__distance_lte=(madrid, D(km=2)))
# The 'spheroid' option uses ST_DistanceSpheroid (not on dwithin); the number is in the field's units
Bookshop.objects.filter(location__distance_lte=(madrid, 0.01, "spheroid"))
# WHERE ST_DistanceSpheroid("places_bookshop"."location", ST_GeomFromEWKB(<ewkb>),
# SPHEROID["WGS 84",6378137.0,298.257223563]) <= 0.01
dwithin is the indexed one, with a catch that was observed and matches the reference: you can only provide Distance objects if the targeted geometries are in a projected system. On a geographic geometry field the lookup
refuses a Distance object:
Bookshop.objects.filter(location__dwithin=(madrid, D(km=2)))
# ValueError: Only numeric values of degree units are allowed on geographic DWithin queries.
So for dwithin use a projected SRID (delivery_area__dwithin=(point_3857, D(m=500)) compiled to
ST_DWithin("places_bookshop"."delivery_area", ST_GeomFromEWKB(<ewkb>), 500.0)), a geography=True field as in the
first example, or a number of degrees. With a Distance and a distance lookup on a projected field whose geometry
comes in another SRID, Django also transforms the input: ST_Distance(col, ST_Transform(ST_GeomFromEWKB(<ewkb>),
3857)) ⇐ 2000.0.
Source: Distance lookups and Distance queries
Distances as annotations and nearest neighbours
To get the distance as a value rather than a filter, annotate with the Distance function; the result is a Distance
measure object, so row.distance.km and row.distance.mi are available. On PostGIS a WGS 84 geometry column makes
Django pick ST_DistanceSphere (observed), a geography column uses plain ST_Distance, which is already in metres.
GeometryDistance is the PostGIS-only function for nearest-neighbour search: used in order_by() it is index-assisted.
from django.contrib.gis.db.models.functions import Distance, GeometryDistance
Bookshop.objects.annotate(distance=Distance("location", madrid)).order_by("distance")[:5]
# SELECT ..., ST_DistanceSphere("places_bookshop"."location", ST_GeomFromEWKB(<ewkb>)) AS "distance" ...
# ORDER BY 3 ASC LIMIT 5
Bookshop.objects.order_by(GeometryDistance("location", madrid))[:3]
# ORDER BY ("places_bookshop"."location" <-> ST_GeomFromEWKB(<ewkb>)) ASC LIMIT 3
The <→ operator behind GeometryDistance is a planar distance in the geometry’s units (degrees on 4326), which is right for ranking and wrong for display; use Distance for the number you show. The <→ ordering is the fast way to answer
"the three closest shops" on a large table. The near() manager method in The models used on this page combines a geography
dwithin filter with a Distance annotation and compiled to one ST_DWithin and one ST_Distance.
Source: Distance and GeometryDistance
Geographic database functions and aggregates
The functions are query expressions in django.contrib.gis.db.models.functions; use them in annotate(),
aggregate(), filter() and order_by(). Availability per backend is listed with each function and summarized in the
compatibility tables.
| Group | Functions | Notes |
|---|---|---|
Measurements |
|
Results are |
Relationships |
|
|
Operations |
|
The function |
Editors |
|
|
Input format |
|
Optional |
Output format |
|
|
Miscellaneous |
|
|
Source: Geographic database functions
from django.contrib.gis.db.models.functions import AsGeoJSON, Intersection, Length, Transform
from django.contrib.gis.measure import D
Bookshop.objects.annotate(web=Transform("location", 3857))
# ST_Transform("places_bookshop"."location", 3857) AS "web"
Bookshop.objects.annotate(gj=AsGeoJSON("location", precision=5))
# ST_AsGeoJSON("places_bookshop"."location", 5, 0) AS "gj"
Region.objects.annotate(overlap=Intersection("boundary", area))
# ST_Intersection("places_region"."boundary", ST_GeomFromEWKB(<ewkb>)) AS "overlap"
CourierRoute.objects.annotate(length=Length("path")).filter(length__gt=D(km=10))
# ST_LengthSpheroid("places_courierroute"."path", SPHEROID["WGS 84",6378137.0,298.257223563]) > 10000.0
Source: Geographic database functions
On a geographic coordinate system, Length defaults to the spheroid calculation (observed: ST_LengthSpheroid),
which is the accurate and more expensive choice; pass spheroid=False for the sphere. The reference also warns about
two backend limits that bite portable code: MySQL and SpatiaLite without LWGEOM/RTTOPO do not support area calculations on geographic SRSes.
For Area on a geometry column in a geographic SRID, the documented route to metric areas is a geography column or a projected SRID (Transform(…) first). It was not possible here to confirm what number PostGIS returns for Area on a WGS 84 geometry column (no server), so verify it on your database before using it in a report.
Aggregates
The GIS aggregates (Collect, Extent, Extent3D, MakeLine, Union) live in django.contrib.gis.db.models and
since Django 5.0 accept the filter argument. Union dissolves boundaries and is processor-intensive on big querysets;
Collect just rolls geometries into a collection or multi geometry and can be several orders of magnitude faster.
Extent returns a 4-tuple (lower-left and upper-right corner); MakeLine builds a LineString from points.
from django.contrib.gis.db.models import Collect, Extent, MakeLine, Union
Region.objects.values("name").annotate(e=Extent("boundary"))
# SELECT "places_region"."name" AS "name", ST_Extent("places_region"."boundary") AS "e" ... GROUP BY 1
Region.objects.values("name").annotate(u=Union("boundary"))
# ST_Union("places_region"."boundary") AS "u"
CourierRoute.objects.values("name").annotate(l=MakeLine("path"))
# ST_MakeLine("places_courierroute"."path") AS "l"
# The documentation's advice: restrict before dissolving
Region.objects.filter(boundary__within=area).aggregate(Union("boundary"))
Source: Aggregate functions
The aggregation machinery itself is described on Aggregation, Expressions & Functions; geographic aggregates plug into it unchanged.
Measurement objects
django.contrib.gis.measure has Distance (alias D) and Area (alias A). They turn "5 km" into something you
can convert, compare and add without a unit mistake, and they are what Distance, Length, Area and the distance
lookups use. Everything below was executed:
from django.contrib.gis.measure import A, Area, D, Distance
d1, d2 = D(km=5), D(mi=5)
print(d1, d2)
# 5.0 km 5.0 mi
d1.mi, d2.km
# (3.1068559611866697, 8.04672)
print(d1 + d2, d2 - d1)
# 13.04672 km 1.8931440388133304 mi
print(d1 * d2) # distance times distance is an Area
# 40.2336 sq_km
D(m=1500).km, D(ft=1).m, D(km=3) > D(mi=1)
# (1.5, 0.3048, True)
Area(sq_mi=5).sq_km, A(ha=2).sq_m
# (12.949940551680001, 20000.0)
Distance.unit_attname("US Survey Foot"), Area.unit_attname("Kilometer")
# ('survey_ft', 'sq_km')
Source: Measurement objects
Unit attributes include km, mi, m, yd, ft, survey_ft, inch, cm, mm, um, nm (nautical mile),
fathom, rod, furlong, chain and many historical ones (british_ft, clarke_ft, link, german_m and others).
Area uses the same names prefixed with sq_, plus ha (hectare, added in 5.1). unit_attname() maps a full name to
the attribute. Arithmetic works between objects of the same kind and with numbers, and comparisons convert units for
you.
Forms and widgets
GeoDjango’s form fields (GeometryField, PointField, LineStringField, PolygonField, the multi types and
GeometryCollectionField, in django.contrib.gis.forms) display and edit geometries on an OpenLayers map. A model
form picks the right one automatically from the model field; this is how the admin gets its map.
| Argument or attribute | Meaning |
|---|---|
|
The SRID the cleaned value is transformed to. |
|
Set by the field class; you normally do not touch it. |
|
Maximum geometry collections accepted before the field raises |
|
Identifier of the JavaScript base map layer; |
|
SRID of the map; default 4326 on the abstract base, 3857 on both concrete widgets (observed). |
|
Show a textarea with the serialized geometry (debugging). |
|
3D support flag (false for all built-in widgets); the template. |
|
Initial map centre and zoom; defaults 47, 5 and 12 (eastern France). |
Source: GeoDjango Forms API
from django.contrib.gis import forms
class ShopForm(forms.Form):
point = forms.PointField(
srid=4326,
widget=forms.OSMWidget(attrs={"default_lat": 40.4, "default_lon": -3.7, "default_zoom": 12}),
)
Two behaviours showed up while running it. The first is a real trap: a widget submits coordinates in its own
map_srid (3857), so a GeoJSON point with plain longitude and latitude and no CRS is read as Web Mercator metres.
form = ShopForm({"point": '{"type":"Point","coordinates":[-3.7,40.4]}'})
form.is_valid(), form.cleaned_data["point"].wkt
# (True, 'POINT (-3.323766551242229e-5 0.0003629193747812)') # -3.7 m east of the origin, reprojected to degrees
form = ShopForm({"point": '{"type":"Point","coordinates":[-412305,4926696]}'})
form.cleaned_data["point"].x # about -3.7038
When you build such forms for an API or a test, send coordinates in map_srid, or give the widget the SRID you really
send. The second behaviour is validation: a polygon posted to a PointField fails with "Invalid geometry type.", and a
GEOMETRYCOLLECTION nested 300 deep fails with "Invalid geometry value." instead of crashing GEOS.
Source: Field arguments
The widgets load ol.js and ol.css from the jsDelivr CDN (OpenLayers 10.9.0 since Django 6.1, previously 7.2.2; the
rendered media in the scratch project listed ol@v10.9.0). Override them by subclassing the widget and setting
Media.js/Media.css to local copies if you cannot use a CDN. Under the built-in Content Security Policy
middleware the CDN scripts are blocked unless you allow them in SECURE_CSP or serve local copies; the OSM widget also
needs the tile server in img-src (documented: https://tile.openstreetmap.org, and
https://*.earthdata.nasa.gov for the NASA layer). See
Content Security Policy for the policy mechanics and
Form Rendering & Widgets for subclassing widgets and Media. Django 6.0
removed the inline JavaScript from the widget templates and the gis/openlayers-osm.html template; if you customized
those templates you must port them.
A custom base layer is a JavaScript layer builder (MapWidget.layerBuilder.custom_layer_name = function () { … }
returning an OpenLayers layer) plus a widget subclass with base_layer = "custom_layer_name" and the script in
Media.js.
Source: Form widgets
The GIS admin
Registering a model with geometry fields in the ordinary admin already gives you a map: ModelAdmin uses the model
field’s default form field, which uses OpenLayersWidget (observed on the scratch project). For an OpenStreetMap layer
with street detail use GISModelAdmin from django.contrib.gis.admin. It has two attributes: gis_widget, the widget
class for GeometryField (default OSMWidget), and gis_widget_kwargs, the keyword arguments passed to it (default
empty).
from django.contrib.gis import admin
from .models import Bookshop, Region
@admin.register(Bookshop)
class BookshopAdmin(admin.GISModelAdmin):
list_display = ["name"]
gis_widget_kwargs = {
"attrs": {
"default_lon": -3.7038,
"default_lat": 40.4168,
"default_zoom": 12,
},
}
admin.site.register(Region, admin.GISModelAdmin)
Executed: manage.py check passed with both registrations, and building the form through BookshopAdmin.get_form()
gave an OSMWidget whose attributes were default_zoom 12 and the shop-centred defaults, with map_srid 3857. The
admin page itself was not rendered in a browser. Notes:
-
GISModelAdminneeds the PROJ datum shifting files to be installed, per the tutorial. -
The old
GeoModelAdminandOSMGeoAdminclasses were deprecated in 4.0 (whenGISModelAdminwas added) and removed in 5.0. OnlyAdminSite,GISModelAdminandModelAdminremain indjango.contrib.gis.admin(checked withdir()). -
A 3D field (
dim=3) falls back to a plain textarea. -
The admin is the usual place where the lookup security fix mattered: its changelist filters by lookups, see Security: the 2026 fixes and what to validate.
The admin machinery (permissions, list_display, inlines) is on Admin Site and
Admin Customization.
Serving geometries to a map: the GeoJSON serializer
Browsers draw GeoJSON. django.core.serializers.serialize("geojson", queryset, …) produces a FeatureCollection from
model instances. Besides the options of the json serializer it takes geometry_field (needed when a model has
several geometry fields; default is the first), id_field (default the primary key), srid (default 4326) and
fields (limits properties). It is output only: there is no deserializer, so loaddata cannot reload it.
from django.core.serializers import serialize
from places.models import Bookshop
shop = Bookshop(pk=1, name="Libros", location=madrid)
serialize("geojson", [shop], geometry_field="location", fields=["name"])
# {"type": "FeatureCollection", "features": [{"type": "Feature", "id": 1, "properties": {"name": "Libros"},
# "geometry": {"type": "Point", "coordinates": [-3.7038, 40.4168]}}]}
serialize("geojson", [shop], geometry_field="location", srid=3857, id_field="name", fields=["name"])
# "id": "Libros", ... "coordinates": [-412305.1300001266, 4926696.669635533]
Source: GeoJSON serializer
A complete endpoint for the Bookshelf map. It validates what a client sends, caps the radius, and returns
application/geo+json. The queryset is the near() method from The models used on this page:
from django.contrib.gis.geos import Point
from django.core.serializers import serialize
from django.http import HttpResponse, HttpResponseBadRequest
from .models import Meetup
def nearby_meetups(request):
try:
lon = float(request.GET["lon"])
lat = float(request.GET["lat"])
km = min(float(request.GET.get("km", "5")), 50.0)
point = Point(lon, lat, srid=4326) # validated: Point() rejects non-numbers
except (KeyError, ValueError):
return HttpResponseBadRequest("lon and lat are required numbers")
meetups = Meetup.objects.near(point, km)[:20]
body = serialize("geojson", meetups, geometry_field="venue", fields=["title"])
return HttpResponse(body, content_type="application/geo+json")
from django.urls import path
from . import views
urlpatterns = [path("meetups/nearby/", views.nearby_meetups, name="nearby-meetups")]
With the stubbed Meetup.objects the view returned HTTP 200 with the content type above, one feature whose properties
were {"title": "Poetry night"}, km=999 was capped to 50.0 before reaching the queryset, and lon=x produced a 400.
The real query needs PostGIS. Note that float() accepts nan and inf; add math.isfinite() checks if you expose
the endpoint publicly. For general view mechanics see Function-Based Views & Shortcuts, and for
serialization of other formats Fixtures, Serialization & Initial Data.
Source: GeoJSON serializer
Feeds, sitemaps and GeoIP
Three smaller tools complete the picture; none was run. django.contrib.gis.feeds.Feed is a syndication feed whose
geometry() and item_geometry() methods embed locations in RSS or Atom using the GeoRSSFeed, GeoAtom1Feed (Simple
GeoRSS) or W3CGeoFeed classes (the W3C Geo format supports only points). KMLSitemap and KMZSitemap in
django.contrib.gis.sitemaps publish geolocated data as KML or compressed KMZ; the general machinery is on
Sites, Sitemaps & Feeds. django.contrib.gis.geoip2.GeoIP2 wraps MaxMind’s
geoip2 package and the GeoLite2 or DB-IP .mmdb databases found in GEOIP_PATH; its methods are country(),
city(), lat_lon(), lon_lat() and geos(). The coords() and open() methods were deprecated in 5.1 and removed in
6.0: use lon_lat().
Source: Geographic feeds, Geographic sitemaps and GeoIP2
Testing GeoDjango code
Tests that touch spatial lookups need a spatial database. With PostGIS, Django creates the test database and the extension; if your spatial database template was created like in the instructions, the user only needs the ability to create databases, otherwise a superuser:
createuser --createdb -R -S <user_name>
# or, from a superuser psql session:
# ALTER ROLE <user_name> CREATEDB NOSUPERUSER NOCREATEROLE;
The optional POSTGIS_VERSION = (X, Y, Z) setting tells GeoDjango the PostGIS version and saves the version query at start-up; together with the stubs described in What was run, and what was not it let the scratch project run check and makemigrations without a server. Anything that needs no
rows, such as geometry arithmetic, form validation, widget configuration and SQL shape, can be tested with
SimpleTestCase and no database at all, which is what the 21 tests in the scratch project do:
import re
from django.contrib.gis.geos import GEOSGeometry, LineString, Point
from django.contrib.gis.measure import D
from django.test import SimpleTestCase
from .models import Meetup
MADRID = Point(-3.7038, 40.4168, srid=4326)
def sql(queryset):
"""The SQL Django would send, with the binary geometry literal elided."""
return re.sub(r"E'(?:[^'\\]|\\.)*'::bytea", "<ewkb>", str(queryset.query))
class GeometryTests(SimpleTestCase):
def test_equality_is_not_spatial_equality(self):
ls1, ls2 = LineString((0, 0), (1, 1)), LineString((1, 1), (0, 0))
self.assertTrue(ls1.equals(ls2))
self.assertFalse(ls1 == ls2)
def test_nested_collection_limit(self):
deep = "GEOMETRYCOLLECTION(" * 300 + "POINT(0 0)" + ")" * 300
with self.assertRaises(ValueError):
GEOSGeometry(deep)
class NearbyTests(SimpleTestCase):
def test_queryset_sql(self):
text = sql(Meetup.objects.near(MADRID, 3))
self.assertIn("ST_DWithin(", text)
self.assertIn("ST_Distance(", text)
def test_dwithin_with_distance_on_geographic_geometry_is_rejected(self):
from .models import Bookshop
with self.assertRaisesMessage(ValueError, "Only numeric values of degree units"):
str(Bookshop.objects.filter(location__dwithin=(MADRID, D(km=2))).query)
Those SQL-shape tests ran only because the scratch project stubbed the server: on a plain PostGIS configuration, compiling a distance lookup reads the SRID units from the spatial_ref_sys table (the first attempt without the stub hung on that query), so they need a live PostGIS. Anything that returns rows must be a TestCase against a real PostGIS (marked or tagged so it is skipped where
PostGIS is absent). The general test tools are on the testing pages of this section; the settings for running Django’s
own GIS suite are in the documentation.
Source: Testing GeoDjango apps
Sync, async and threads
The GeoDjango reference describes no async-specific API: the ORM’s async methods (aget(), acreate(), async for over a queryset) are the way to reach spatial fields from async code, and GEOS and GDAL calls are plain synchronous,
CPU-bound functions that block the event loop for as long as they run. In an async view run heavy geometry work with
sync_to_async or a worker (see Tasks Framework for background work). The Deploying GeoDjango page adds a warning that deserves attention: GDAL is not thread safe at this time, so it recommends not using threads when deploying; for example set threads to 1 in a mod_wsgi daemon and raise the number
of processes instead. Apply the same reasoning to any threaded server configuration.
Source: Deploying GeoDjango
Security: the 2026 fixes and what to validate
GeoDjango turns user-supplied text into parsers, file handles and SQL fragments, and 2026 produced five advisories in it (all listed in the security archive). Read them as a checklist of what can go wrong:
| Fixed | Issue | What changed |
|---|---|---|
2026-02-03 (6.0.2, with 5.2 and 4.2 patches) |
CVE-2026-1207, severity high: SQL injection through raster lookups on PostGIS |
Untrusted data used as a band index ( |
2026-07-07 (6.0.7, with the 6.1 and 5.2 patches) |
CVE-2026-53877, severity low: heap buffer over-read in |
Building a |
2026-08-04 (6.0.8, with the 6.1 and 5.2 patches) |
CVE-2026-15307, severity high: server-side file write and request forgery via spatial lookups |
Spatial lookups used to pass |
2026-08-04 (6.0.8, with the 6.1 and 5.2 patches) |
CVE-2026-15830, severity moderate: denial of service through nested geometry collections |
Deeply nested |
Source: Archive of security issues and the release notes 6.0.2, 6.0.7 and 6.0.8
What to take from it:
-
Never pass raw user strings, paths or dictionaries into a spatial lookup. Parse and validate the input first, build a
GEOSGeometry, and pass that. The fix makes the dangerous inputs fail (observed in 6.1.1: adictraised aSuspiciousOperationsubclass and"/etc/passwd"raisedValueError: String input unrecognized as WKT EWKT, and HEXEWKB). If a trusted piece of code really needs to use a path or dictionary raster in a lookup, wrap it explicitly inGDALRaster(…); the documentation says that explicit wrapping signals that the value is trusted. -
Assignments to model fields were not changed.
Elevation(rast={…})opens a new raster and a string path is fetched, so those fields must never receive request data without your own validation. TheGeometryFieldform field rejects raster values when validating geometry input; for raster input you write the validation. -
Treat the admin as an entry point.
lookup_allowed()lets staff filter the changelist by lookup, so a vulnerable spatial field was reachable by any staff user who can view the model. -
Limit the drivers GDAL may use where you can (the documentation links GDAL’s own security considerations for defence in depth), and keep coordinates numeric and finite.
-
Keep Django patched. The SQL injection fix landed in 6.0.2, the over-read in 6.0.7 and the other two in 6.0.8 and the matching 6.1 and 5.2 releases; the 5.2 LTS receives them too. The Django 6.1.2 release notes page (dated 2026-10-06, not yet on PyPI when this page was written) also lists "one data loss issue in 4.0": network rasters were deleted by GeoDjango when closed. If you open remote rasters with
GDALRasterand close them, wait for 6.1.2 or the matching 5.2 patch release before relying on it, and do not pointGDALRasterat a network resource you cannot afford to lose.
General Django hardening is on Security; the CSP side of the map widgets is under Forms and widgets.
Pitfalls checklist
-
Point(x, y)is longitude then latitude;lat, lonpairs pasted from a map site are swapped. -
A distance in degrees is not a distance in metres. Use
geography=True, a projected SRID, or theDistancefunction and read.m/.km. -
dwithinwith aD(…)on a geographic geometry field raisesValueError; use a geography or projected field, or degrees. -
==on geometries isequals_exact(); useequals()for spatial equality.exacton PostGIS compares bounding boxes. -
A plain
Polygoncannot be assigned to aMultiPolygonField(TypeError);LayerMappingconverts, assignment does not. -
ogrinspecton GeoJSON writesmax_length=0for strings; fix the generated model. -
Form widgets submit coordinates in
map_srid(3857); a longitude/latitude GeoJSON without a CRS is misread. -
Do not use the
Unionaggregate on a whole table: filter first, or useCollect. -
Geography columns support only a subset of lookups;
Castto geometry when you need another one. -
Create GEOS objects inside functions, not at module level.
-
GDAL is not thread safe: keep threads at 1 per process, scale with processes.
-
Never feed request strings, paths or dictionaries to spatial lookups or raster fields.
-
inspectdband the map widgets only work withdjango.contrib.gisinINSTALLED_APPS. -
Without
GEOS_LIBRARY_PATH/GDAL_LIBRARY_PATHset to the full library file, "library not found" is the first error you will see; on Linux installbinutilstoo. -
Spatial test databases need a user that may create them (and the extension, so possibly a superuser).
Where the books are outdated
The four reference books for this section target Django 1.11 to 3.x, and tutorials of that period still circulate. For GeoDjango the differences are:
| Older practice | Current practice | Changed in |
|---|---|---|
|
|
removed in 2.0 |
|
|
|
|
|
added and old ones deprecated in 4.0, removed in 5.0 |
The undocumented |
|
deprecated 4.1, removed 5.0 |
|
|
removed in 2.0 |
|
|
deprecated 5.1, removed 6.0 |
PostGIS 2.x, GEOS 3.4-3.9, GDAL 1.11-3.2, SpatiaLite 4.1-4.2 |
PostGIS 3.2-3.6, GEOS 3.10-3.14, GDAL 3.3-3.13, SpatiaLite 4.3-5.1 |
successive releases up to 6.1 |
Accepting any string in a spatial lookup |
Wrap rasters in |
security fix 2026 |
Source: 2.0 release notes, 4.0, 5.0 and 6.0
Other things older material gets wrong or omits: it assumes a manual spatial-template database (createdb -T
template_postgis), where today you use CREATE EXTENSION or the CreateExtension operation; it predates
geography=True guidance, the GeoJSON serializer’s id key (4.2), PROJ-based transformation with GDAL 3 axis handling,
and the 2026 input restrictions above. Do not copy a 1.x-era query like City.objects.distance(pnt); it does not exist
any more.
What changed recently
| Version | Change |
|---|---|
3.0 |
|
3.1 |
PostGIS 3 and GDAL 3 supported; |
3.2 |
|
4.0 |
|
4.1 |
|
4.2 |
The GeoJSON serializer writes the |
5.0 |
|
5.1 |
Measured geometries ( |
5.2 (LTS) |
Curved geometries in GDAL ( |
6.0 |
|
6.0.2 / 6.0.7 / 6.0.8 (2026) |
Security fixes: raster-lookup SQL injection, |
6.1 |
|
6.1.2 (scheduled 2026-10-06) |
Fixes a data loss issue from 4.0: network rasters were deleted by GeoDjango when closed. |
Source: Django 6.1 release notes
For the 5.2 LTS the lookups and functions marked 6.0 and 6.1 above (geom_type, GeometryType, Rotate,
num_dimensions, NumDimensions) do not exist, and 5.2 supports PostGIS 3.1 and GEOS 3.8; the security fixes were also
released for 5.2. Before upgrading, run the tests with python -Wd manage.py test; 6.2 (the next LTS, planned for April
2027) is the last release under the old versioning, and from 2028 releases are named by year.