Deploy GeoDjango with PostGIS in Production

Deploy a GeoDjango app: GDAL, GEOS, and PROJ libraries on servers and in Docker, enabling PostGIS on self-hosted and managed PostgreSQL, spatial indexes, backups, and upgrades.

Problem statement

GeoDjango adds spatial fields, queries, and admin widgets to Django, backed by PostGIS. It also adds native dependencies that a normal Django deployment does not have: the GDAL, GEOS, and PROJ libraries on every machine that runs Django, and the PostGIS extension in the database.

Production failures are therefore different from ordinary Django ones: ImproperlyConfigured: Could not find the GDAL library at startup, permission denied to create extension "postgis" during migrate, container images that balloon by hundreds of megabytes, and PostgreSQL upgrades blocked by mismatched PostGIS versions. This guide covers each.

GeoDjango runtime piecesDjango appgis backendGDAL, GEOS, PROJnative librariesPostgreSQLpostgis extensionSpatial indexGiST on geometryEvery Django process needs the native libraries; only the database needs PostGIS itself.
  1. Install GDAL, GEOS, and PROJ system libraries on app servers and in the runtime image (gdal-bin on Debian and Ubuntu pulls them in).
  2. Set ENGINE to django.contrib.gis.db.backends.postgis and add django.contrib.gis to INSTALLED_APPS.
  3. Enable PostGIS with a privileged role (CREATE EXTENSION postgis;) before the app's first migrate.
  4. On managed PostgreSQL, use the provider's documented way to enable PostGIS (usually allowed for the admin role).
  5. Keep the default spatial index on geometry fields and check query plans for spatial filters.
  6. Plan PostGIS upgrades together with PostgreSQL major upgrades.

Step-by-step solution

1. Install native libraries on servers

On Debian or Ubuntu:

sudo apt install -y binutils gdal-bin libproj-dev
python3 -c "from ctypes.util import find_library; print(find_library('gdal'), find_library('geos_c'))"

GeoDjango finds the libraries through standard library paths. If they are in an unusual location, point Django at them explicitly:

GDAL_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgdal.so"
GEOS_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgeos_c.so"

Hardcoding paths ties settings to one distribution and architecture, so use these only when automatic discovery fails.

2. Settings

INSTALLED_APPS = [
    # ...
    "django.contrib.gis",
]

DATABASES = {
    "default": {
        "ENGINE": "django.contrib.gis.db.backends.postgis",
        "NAME": "myapp",
        "USER": "myapp",
        "PASSWORD": os.environ["DATABASE_PASSWORD"],
        "HOST": os.environ["DATABASE_HOST"],
        "CONN_MAX_AGE": 60,
    }
}

If you use dj-database-url, the URL scheme postgis:// selects the GIS backend.

3. Build a Docker image with GDAL

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update \
 && apt-get install -y --no-install-recommends gdal-bin libproj-dev \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN DJANGO_SETTINGS_MODULE=config.settings.build python manage.py collectstatic --noinput
USER nobody
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
GeoDjango sits on native librariesdjango.contrib.gisyour spatial models and queriesGDALraster and vector formatsPROJcoordinate transformationsGEOSgeometry operationsPostGIS in PostgreSQLspatial types and indexesThe app server and every worker need the same library versionsdjango-deployment.com
Celery workers and management commands import the same libraries as the web process.

GDAL is large. To keep images smaller, install only the runtime library package (for example libgdal with its versioned name for your Debian release) rather than gdal-bin, and keep compilers out of the final stage. Avoid installing the GDAL Python package from PyPI unless you need its bindings directly; GeoDjango uses ctypes and does not need it, and building it requires matching development headers.

Verify inside the image:

docker run --rm myapp:latest python -c "from django.contrib.gis import gdal, geos; print(gdal.gdal_version(), geos.geos_version())"

4. Enable PostGIS in the database

CREATE EXTENSION postgis requires elevated privileges. With a self-hosted server:

sudo apt install -y postgresql-16-postgis-3
sudo -u postgres psql -d myapp -c "CREATE EXTENSION IF NOT EXISTS postgis;"
sudo -u postgres psql -d myapp -c "SELECT postgis_full_version();"
PostGIS before the first migrate1Install the matching packagepostgresql-16-postgis-32CREATE EXTENSION postgisas a superuser or admin role3SELECT postgis_full_version()confirm GEOS and PROJ4migrate as the app userno superuser needed now5Same extension in CIpostgis/postgis imageThe app user usually cannot create extensions itselfdjango-deployment.com
On managed services the admin role replaces the superuser for this one step.

The PostGIS package must match the PostgreSQL major version. Django's PostGIS backend tries CREATE EXTENSION IF NOT EXISTS postgis when it prepares the database, which fails with a permission error if the application role is not privileged; creating the extension beforehand as an administrator avoids that.

On managed services, PostGIS is available but enabled differently:

  • Amazon RDS / Aurora PostgreSQL: connect as the master user (member of rds_superuser) and run CREATE EXTENSION postgis;.
  • Cloud SQL, Azure Database for PostgreSQL: run CREATE EXTENSION postgis; as the admin user; Azure also requires the extension to be allow-listed in server parameters (azure.extensions).
  • Check your provider's supported PostGIS versions before choosing a PostgreSQL version.

For containers in development and CI, the postgis/postgis image matches production closely:

services:
  db:
    image: postgis/postgis:16-3.4

5. Spatial indexes and query performance

Geometry and geography fields get a GiST spatial index by default (spatial_index=True). Check that spatial filters use it:

from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D

qs = Store.objects.filter(location__dwithin=(Point(13.40, 52.52, srid=4326), D(km=5)))
print(qs.explain())
keep spatial filters on the indexSkips the GiST index✕ST_Transform inside the filter✕distance_lte for a radius search✕metres on SRID 4326 geometryUses the index✓data stored in the queried SRID✓dwithin for radius searches✓geography or projected SRIDqueryset.explain() shows whether the index is useddjango-deployment.com
Look for an Index Scan on the spatial index rather than a sequential scan.

Things that often prevent index use:

  • Filtering on a transformed geometry (ST_Transform in the query) instead of storing data in the SRID you query with.
  • Using distance_lte where dwithin would do; dwithin is index-assisted.
  • Distance on geometry fields in degrees (SRID 4326) when you meant metres; use a geography field or a projected SRID for metric distances.

6. Backups and restores

pg_dump includes PostGIS data and the CREATE EXTENSION statement. Restoring requires the PostGIS extension to be installable on the target server, with a compatible version. Test restores onto the same PostgreSQL and PostGIS versions you run in production.

7. Upgrades

PostGIS has its own versions. After upgrading PostGIS packages within a PostgreSQL major version, update the extension in each database:

SELECT postgis_extensions_upgrade();
SELECT postgis_full_version();

For PostgreSQL major upgrades with pg_upgrade, the new cluster needs the same or a newer PostGIS installed. Managed providers document supported upgrade paths; some require upgrading PostGIS first.

Verification check:

python manage.py check --deploy
python manage.py dbshell <<< "SELECT postgis_full_version();"
python manage.py shell -c "from django.contrib.gis.gdal import gdal_version; print(gdal_version())"

Also load a page with a map or spatial query in production and check its response time.

Explanation

GeoDjango splits spatial work between the Python process and the database. Django uses GEOS for geometry objects, GDAL for data formats and coordinate transformations, and PROJ for projections, all loaded through ctypes from system libraries. PostGIS provides spatial types, functions, and indexes in PostgreSQL. A deployment therefore has two dependency chains to maintain: native libraries on every app host or image, and the extension in every database.

Edge cases / notes

Celery and management commands

Every process that imports your models needs the native libraries, including Celery workers, cron jobs, and one-off containers. A worker image built without GDAL fails at import time even if it never touches spatial data.

ARM64 images

Debian packages for GDAL and PostGIS are available for ARM64, so multi-architecture images work; build and test both variants.

When to automate this

Add the library check (gdal_version(), geos_version()) to your image build or CI, and include CREATE EXTENSION IF NOT EXISTS postgis in database provisioning, not in manual runbooks. Both steps are easy to forget on a new environment.

Next steps

FAQ

Why does Django say it could not find the GDAL library?

GDAL is not installed on that machine or image, or it is in a non-standard path. Install the system package or set GDAL_LIBRARY_PATH.

Why does migrate fail with "permission denied to create extension postgis"?

The application role cannot create extensions. Create PostGIS once as an administrator before running migrations.

Do I need the GDAL Python package from PyPI for GeoDjango?

No. GeoDjango loads the GDAL shared library directly. The PyPI package is only needed for direct use of its Python bindings.

Does GeoDjango work with managed PostgreSQL?

Yes. RDS, Cloud SQL, Azure, and most managed providers support PostGIS. Enable it with the admin role as documented by the provider.

Keep going