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.
- Install GDAL, GEOS, and PROJ system libraries on app servers and in the runtime image (
gdal-binon Debian and Ubuntu pulls them in). - Set
ENGINEtodjango.contrib.gis.db.backends.postgisand adddjango.contrib.gistoINSTALLED_APPS. - Enable PostGIS with a privileged role (
CREATE EXTENSION postgis;) before the app's firstmigrate. - On managed PostgreSQL, use the provider's documented way to enable PostGIS (usually allowed for the admin role).
- Keep the default spatial index on geometry fields and check query plans for spatial filters.
- 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"]
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();"
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 runCREATE 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())
Things that often prevent index use:
- Filtering on a transformed geometry (
ST_Transformin the query) instead of storing data in the SRID you query with. - Using
distance_ltewheredwithinwould do;dwithinis index-assisted. - Distance on
geometryfields in degrees (SRID 4326) when you meant metres; use ageographyfield 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
- Configure the connection with How to Connect Django to PostgreSQL in Production.
- Use managed databases safely with Connect Django to Managed PostgreSQL Securely.
- Build the image with How to Dockerize a Django App for Production.
- Plan database version upgrades with Upgrade PostgreSQL to a New Major Version for Django.
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.