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.

Supported databases

Install it

Install GEOS, GDAL and PROJ, create the PostGIS extension, add django.contrib.gis to INSTALLED_APPS, use the django.contrib.gis.db.backends.postgis engine.

Installing on PostgreSQL with PostGIS

A column that holds a place

from django.contrib.gis.db import models and PointField, PolygonField, MultiPolygonField and friends, with an srid.

Geometry fields

Distances in metres on WGS 84

PointField(geography=True) or a projected SRID; never degrees by accident.

Choosing an SRID: degrees, projections and geography

Work with a shape in Python

GEOSGeometry, Point, Polygon, LineString from django.contrib.gis.geos.

The GEOS API: geometries in Python

Read a shapefile or GeoJSON file

DataSource from django.contrib.gis.gdal; LayerMapping to load it into a model; ogrinspect to write the model.

The GDAL API: reading data files, Importing data: LayerMapping and ogrinspect

Find rows by position

Spatial lookups such as intersects, contains, within, dwithin, distance_lte.

Spatial lookups, Distance queries

Compute with geometries in SQL

Distance, Area, Length, Transform, Intersection, AsGeoJSON from django.contrib.gis.db.models.functions; Extent, Union, Collect, MakeLine aggregates.

Geographic database functions and aggregates

Units

D(km=5) and A(sq_m=…​) from django.contrib.gis.measure.

Measurement objects

Edit a shape in the admin

admin.GISModelAdmin from django.contrib.gis.admin.

The GIS admin

Send shapes to a map in a browser

The geojson serializer (serialize("geojson", …​)).

Serving geometries to a map: the GeoJSON serializer

Stay safe with user input

Validate coordinates, never pass raw strings or dictionaries into spatial lookups, keep the 2026 security fixes.

Security: the 2026 fixes and what to validate

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, srid, transform(), buffer(), contains(), equals() versus ==, make_valid(), geojson

no

Executed with real GEOS and PROJ; the printed values below are the observed ones.

DataSource, Layer, Feature on a GeoJSON file, the ogrinspect command, mapping(), an in-memory GDALRaster

no

Executed with real GDAL on a temporary GeoJSON file.

D, Distance, Area arithmetic and conversions

no

Executed.

The geojson serializer and the nearby-meetups view

the queryset

The serializer ran on unsaved model instances. The view ran with Meetup.objects replaced by a stub; the queryset it uses was compiled to SQL separately.

Forms with PointField/OSMWidget, GISModelAdmin widget configuration, max_geom_collections

no

Executed: validation results, the widget classes and their attributes were checked in tests.

Models with spatial fields, Meta indexes, the migration with CreateExtension("postgis")

yes, to create the tables

manage.py check clean; makemigrations generated the migration; makemigrations --check --dry-run reported "No changes detected"; sqlmigrate printed the DDL quoted below, with the server round-trips stubbed. The extension step prints -- (no-op) there, an artefact of the stub.

Spatial lookups, distance lookups, functions and aggregates

yes, to return rows

Compiled to SQL with str(queryset.query) against the real PostGIS backend classes (POSTGIS_VERSION set to skip the version query, SRID metadata read from GDAL/PROJ instead of the spatial_ref_sys table). The SQL fragments quoted below come from that; the geometry literal is elided as <ewkb>. No result sets were observed.

LayerMapping.save(), inspectdb, GeoIP2, KMLSitemap, GeoRSSFeed, raster lookups, RasterField

yes, or a data file

Imports and signatures checked only; copied from the documentation otherwise. Not run.

Installation steps, CREATE EXTENSION, container image

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 intersects and dwithin, geographic database functions such as Distance and Transform, and GIS aggregates such as Extent and Union.

  • 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, the ogrinspect command, a GeoJSON serializer, geographic feeds and KML sitemaps, GeoIP2.

flowchart TD A["Your code: models, views, forms, admin"] --> B["django.contrib.gis
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 RasterField and raster lookups.

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.

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

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:

Debian and Ubuntu (documented command)
sudo apt-get install binutils libproj-dev gdal-bin

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:

config/settings.py (library paths from the environment)
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:

A throwaway PostGIS database (not run here)
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:

places/migrations/0001_initial.py (first 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
    ]

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

config/settings.py
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.

The models used on this page

places/models.py
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

GeometryField

The base class; any geometry type.

PointField

A Point.

LineStringField

A LineString.

PolygonField

A Polygon.

MultiPointField, MultiLineStringField, MultiPolygonField

The matching multi-geometry.

GeometryCollectionField

A GeometryCollection.

RasterField

A GDALRaster; implemented only for PostGIS.

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

srid

The spatial reference system identifier. Default 4326 (WGS 84, units are degrees of longitude and latitude). -1 disables automatic SRID transformation on save: the assigned geometry’s SRID is set to -1 and any explicit SRID is discarded.

spatial_index

Default True. Creates a spatial index, usually a variant of an R-tree (GiST on PostGIS). This is not db_index: spatial indexes are created differently from B-tree indexes.

dim

Coordinate dimension, default 2; 3 is supported on PostGIS and SpatiaLite only. The map widgets do not support 3D, so Django falls back to a Textarea for such fields (observed on a PointField(dim=3)).

geography

True creates a geography column instead of geometry. PostGIS only; forces SRID 4326.

max_geom_collections

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).

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

sqlmigrate places 0001 (observed, abridged)
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.

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:

  1. 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.

  2. Geographic system with distance functions (4326 with geometry columns): Django asks PostGIS for ST_DistanceSphere or ST_DistanceSpheroid. Correct, but the spatial index on traditional geometry fields cannot be used, so the distance is computed for every row.

  3. 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 only bboverlaps, coveredby, covers and intersects.

When you need a geography column in a lookup or aggregate that does not support it, cast it to a geometry in the query:

Cast a geography column to geometry for one 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.

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:

Assigning geometries (executed without saving)
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:

Type checks happen on assignment (observed)
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'>

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

An in-memory raster
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.

Creating geometries (executed)
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:

Predicates, constructive operations, validity (executed)
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 ==.

Spatial equality versus == (executed)
ls1 = LineString((0, 0), (1, 1))
ls2 = LineString((1, 1), (0, 0))
ls1.equals(ls2), ls1 == ls2
# (True, False)

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:

Inspecting a data source (executed)
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

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.

Generating a model from a data file (executed, GeoJSON input)
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.

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:

places/load.py (not run: needs a database)
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)

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.

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.

Spatial lookups (SQL compiled against the PostGIS backend)
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

contains

the field geometry spatially contains the value

ST_Contains

contains_properly

the value intersects the interior of the field, but not its boundary or exterior

ST_ContainsProperly

within

the field is inside the value

ST_Within

covers, coveredby

no point of one lies outside the other (differs from contains/within at boundaries)

ST_Covers, ST_CoveredBy

intersects

the geometries share any point

ST_Intersects

disjoint

they share no point

ST_Disjoint

crosses, overlaps, touches, equals

the matching DE-9IM predicate

ST_Crosses, ST_Overlaps, ST_Touches, ST_Equals

relate

the intersection matrix matches a nine-character pattern; value is (geom, pattern)

ST_Relate

exact, same_as

"equal"; on PostGIS this compares bounding boxes, elsewhere spatial equality

the ~= operator

bbcontains, bboverlaps, contained

bounding-box tests only

the ~, && and @ operators

left, right, overlaps_left, overlaps_right, overlaps_above, overlaps_below, strictly_above, strictly_below

position of bounding boxes (PostGIS only)

the box operators

isvalid, isempty

validity, emptiness

ST_IsValid, ST_IsEmpty

geom_type

geometry type name, for example "POLYGON" (new in 6.0)

GeometryType

num_dimensions

number of dimensions, for example 2 or 3 (new in 6.1; PostGIS and SpatiaLite)

ST_NDims

distance_lt, distance_lte, distance_gt, distance_gte, dwithin

distance comparisons, see Distance queries

ST_Distance family, ST_DWithin

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.

Distance lookups on a geography column (SQL compiled)
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:

Distance lookups on a WGS 84 geometry column
# 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:

dwithin with a Distance on a geographic geometry field (observed)
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.

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.

Annotating and ordering by distance (SQL compiled)
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.

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

Area, Distance, GeometryDistance, Length, Perimeter

Results are Area or Distance measure objects. Length(expr, spheroid=True). On MySQL, Distance on geodetic data returns a plain float and Length is not supported on geographic SRSes.

Relationships

Azimuth, BoundingCircle, Centroid, ClosestPoint, Envelope, LineLocatePoint, PointOnSurface

Azimuth returns radians (north is 0, clockwise); BoundingCircle(expr, num_seg=48); LineLocatePoint returns 0 to 1.

Operations

Difference, Intersection, SymDifference, Union

The function Union(expr1, expr2) is not the aggregate Union(geo_field).

Editors

ForcePolygonCW, MakeValid, Reverse, Rotate (6.0), Scale, SnapToGrid, Transform, Translate

Transform(expr, srid) reprojects; the SRID numbers can differ between databases.

Input format

FromWKB, FromWKT

Optional srid.

Output format

AsGeoJSON, AsGML, AsKML, AsSVG, AsWKB, AsWKT, GeoHash

AsGeoJSON(expr, bbox=False, crs=False, precision=8) gives only the geometry member of a GeoJSON feature.

Miscellaneous

GeometryType (6.0), IsEmpty, IsValid, MemSize, NumDimensions (6.1), NumGeometries, NumPoints

IsValid and IsEmpty return booleans.

Functions in annotations (SQL compiled against PostGIS)
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

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.

Aggregates grouped by region (SQL compiled)
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"))

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:

Distance and Area objects (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')

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

srid (field)

The SRID the cleaned value is transformed to.

geom_type (field)

Set by the field class; you normally do not touch it.

max_geom_collections (field)

Maximum geometry collections accepted before the field raises ValidationError; default 198. Counts nesting depth for WKT and the total for WKB; not applied to GeoJSON, which GDAL parses.

base_layer (widget, 6.0)

Identifier of the JavaScript base map layer; OpenLayersWidget uses "nasaWorldview", OSMWidget "osm".

map_srid (widget)

SRID of the map; default 4326 on the abstract base, 3857 on both concrete widgets (observed).

display_raw

Show a textarea with the serialized geometry (debugging).

supports_3d, template_name

3D support flag (false for all built-in widgets); the template.

default_lat, default_lon, default_zoom (OSMWidget)

Initial map centre and zoom; defaults 47, 5 and 12 (eastern France).

A form with a map (executed: validation only)
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.

Input is interpreted in the widget’s map SRID (observed)
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).

places/admin.py
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:

  • GISModelAdmin needs the PROJ datum shifting files to be installed, per the tutorial.

  • The old GeoModelAdmin and OSMGeoAdmin classes were deprecated in 4.0 (when GISModelAdmin was added) and removed in 5.0. Only AdminSite, GISModelAdmin and ModelAdmin remain in django.contrib.gis.admin (checked with dir()).

  • 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.

Serializing (executed, unsaved instances)
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]

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:

places/views.py
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")
places/urls.py
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.

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().

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:

Test database user (documented commands)
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:

places/tests.py (adapted excerpt; the full module of 21 tests passed)
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.

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.

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 (rast<band>contains) was injected into SQL. Validate band indexes as integers.

2026-07-07 (6.0.7, with the 6.1 and 5.2 patches)

CVE-2026-53877, severity low: heap buffer over-read in GDALRaster

Building a GDALRaster from a bytes raster could over-read about 32 bytes of the buffer (information disclosure or a crash). Only rasters in GDAL’s virtual filesystem were affected.

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 str and dict values to GDALRaster; depending on the driver that could write a file (in some cases leading to remote code execution) or send a network request. The admin changelist made it reachable by staff with view permission on any model with a spatial field. Lookups now reject dict, pathlib.Path and any str that is not valid GEOS input. This is backward incompatible.

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 GEOMETRYCOLLECTION input crashed GEOS with a segmentation fault. A maximum of 198 collections is enforced (nesting depth for WKT, total count for WKB); configure it with the new max_geom_collections argument on GEOSGeometry, the form field and the model field. GeoJSON is not affected, because GDAL parses it.

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: a dict raised a SuspiciousOperation subclass and "/etc/passwd" raised ValueError: 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 in GDALRaster(…​); 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. The GeometryField form 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 GDALRaster and close them, wait for 6.1.2 or the matching 5.2 patch release before relying on it, and do not point GDALRaster at 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, lon pairs pasted from a map site are swapped.

  • A distance in degrees is not a distance in metres. Use geography=True, a projected SRID, or the Distance function and read .m/.km.

  • dwithin with a D(…​) on a geographic geometry field raises ValueError; use a geography or projected field, or degrees.

  • == on geometries is equals_exact(); use equals() for spatial equality. exact on PostGIS compares bounding boxes.

  • A plain Polygon cannot be assigned to a MultiPolygonField (TypeError); LayerMapping converts, assignment does not.

  • ogrinspect on GeoJSON writes max_length=0 for 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 Union aggregate on a whole table: filter first, or use Collect.

  • Geography columns support only a subset of lookups; Cast to 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.

  • inspectdb and the map widgets only work with django.contrib.gis in INSTALLED_APPS.

  • Without GEOS_LIBRARY_PATH/GDAL_LIBRARY_PATH set to the full library file, "library not found" is the first error you will see; on Linux install binutils too.

  • 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

GeoManager and the GeoQuerySet methods (.distance(), .area(), .transform(), …​)

GeometryField models use the normal manager; do spatial work with lookups, functions (Distance, Area, Transform) and aggregates

removed in 2.0

django.contrib.gis.geoip

django.contrib.gis.geoip2.GeoIP2 and the MaxMind .mmdb databases

geoip removed in 2.0

GeoModelAdmin and OSMGeoAdmin

GISModelAdmin

added and old ones deprecated in 4.0, removed in 5.0

The undocumented django.contrib.gis.admin.OpenLayersWidget

django.contrib.gis.forms.OpenLayersWidget or OSMWidget

deprecated 4.1, removed 5.0

OGRException

GDALException

removed in 2.0

GeoIP2.coords(), GeoIP2.open(), and setting OGRGeometry.coord_dim

lon_lat(), the GeoIP2 constructor, and set_3d()

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 GDALRaster; pass validated GEOSGeometry objects

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

GeometryDistance function (PostGIS); lookups on MySQL operate on real geometries instead of bounding boxes; furlong unit; pathlib.Path for GEOIP_PATH and GeoIP2.

3.1

PostGIS 3 and GDAL 3 supported; relate on MariaDB; AsWKB and AsWKT; LinearRing.is_counterclockwise.

3.2

DataSource and LayerMapping accept pathlib.Path; GDALRaster.transform() accepts a SpatialReference.

4.0

GISModelAdmin (the old classes deprecated); SpatiaLite 5; GDALRaster in GDAL virtual filesystems.

4.1

GEOSGeometry.make_valid(); the clone argument of normalize().

4.2

The GeoJSON serializer writes the id key; GDALRaster accepts pathlib.Path; GeoIP2 reads DB-IP .mmdb files; the OpenLayers widget stopped using inline CSS; isempty and IsEmpty filter empty geometries on PostGIS.

5.0

ClosestPoint; the filter argument on GIS aggregates; GDAL 3.7 and GEOS 3.12; GEOSGeometry.equals_identical(); GeoModelAdmin, OSMGeoAdmin and the old OpenLayersWidget removed.

5.1

Measured geometries (OGRGeometry.is_measured), is_3d, set_3d(); Area unit ha; BoundingCircle on SpatiaLite 5.1+; Collect on MySQL 8.0.24+; GeoIP2 takes ipaddress objects and returns more keys; PostGIS 2.5, PROJ below 6 and GDAL 2.4 dropped.

5.2 (LTS)

Curved geometries in GDAL (has_curve, get_linear_geometry(), get_curve_geometry()); coveredby/covers on MySQL; PostGIS 3.0 and GDAL 3.0 dropped.

6.0

Rotate function; geom_type lookup and GeometryType function; GEOSGeometry.hasm; BaseGeometryWidget.base_layer; widgets without inline JavaScript; MariaDB 12.0.1+ support for coveredby, isvalid, Collect, GeoHash, IsValid; GeoIP2.coords()/open() removed.

6.0.2 / 6.0.7 / 6.0.8 (2026)

Security fixes: raster-lookup SQL injection, GDALRaster over-read, spatial lookups writing files and fetching URLs, nested geometry collections and the new max_geom_collections (see Security: the 2026 fixes and what to validate).

6.1

num_dimensions lookup and NumDimensions function; isempty and IsEmpty on SpatiaLite; OpenLayers 10.9.0 in the widgets; PostGIS 3.1, GEOS 3.8/3.9 and GDAL 3.1/3.2 dropped; PostgreSQL 15 is the minimum.

6.1.2 (scheduled 2026-10-06)

Fixes a data loss issue from 4.0: network rasters were deleted by GeoDjango when closed.

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.