Upgrade PostgreSQL to a New Major Version for Django

Upgrade the PostgreSQL behind a Django app to a new major version: check Django and psycopg support, choose dump and restore, pg_upgrade, or logical replication, then analyze, verify, and roll back safely.

Problem statement

Every PostgreSQL major version is supported for five years. Running an unsupported version means no security fixes, and new Django releases drop support for old PostgreSQL versions, so a delayed database upgrade eventually blocks your Django upgrades too.

Major upgrades change the on-disk format, so you cannot just install new packages and restart. You need a method that fits your database size and downtime tolerance, checks that Django and psycopg support the target, and a rollback path. This guide compares the three standard methods, walks through them for a Django app, and lists what to do after the switch.

Major upgrade pathsdump and restoresimple, slowerpg_upgradefast, in placelogical replicasminimal downtimeVerify and analyzethen switch DjangoPick the method by database size and downtime budget, and rehearse it on a copy first.
  1. Check compatibility: your Django version's supported PostgreSQL range, your psycopg version, and extensions (PostGIS, pg_trgm) available for the target.
  2. Rehearse on a copy of production and time it.
  3. Small databases or generous windows: pg_dump then pg_restore into the new version.
  4. Larger databases with a short window: pg_upgrade --link (on Debian and Ubuntu, pg_upgradecluster).
  5. Minimal downtime: logical replication from old to new, then switch Django's connection.
  6. After the switch: run vacuumdb --all --analyze-in-stages, update extensions, run Django checks and smoke tests, and keep the old cluster until you are confident.

Step-by-step solution

1. Check compatibility

  • Django: each release documents supported PostgreSQL versions; older PostgreSQL versions are dropped as they approach end of life. Check the "Databases" notes for your Django version and the one you will upgrade to next.
  • psycopg: psycopg 3 and psycopg2 both work with current PostgreSQL versions; upgrade the driver if it is very old.
  • Extensions: install the target version's packages for every extension you use (postgresql-17-postgis-3 alongside postgresql-17, for example).
  • Managed services: check which target versions and upgrade paths the provider supports.
check before you upgradeDjango supports the target versionrelease notesMinimum version not droppedfor your Django releaseExtensions availablePostGIS, pg_trgm versionspsycopg / libpq recent enoughon app serversMost Django apps need no code changes for a PostgreSQL upgradedjango-deployment.com
Extensions are the usual blocker, not Django itself.

List what the database uses:

SELECT extname, extversion FROM pg_extension;
SELECT version();

2. Rehearse on a copy

Restore a recent backup to a scratch server running the old version, perform the upgrade there, point a staging Django instance at the result, and run your test suite and smoke tests. Record the duration: it is your downtime estimate.

three ways to upgradeDowntimeComplexityA: dump and restore✕grows with data size✓lowestB: pg_upgrade!minutes with --linkmediumC: logical replication✓seconds at cutover!highestSmall databases: A. Large: B. Near-zero downtime: Cdjango-deployment.com
Rehearse the chosen method on a copy first.

3. Method A: dump and restore

Simple and reliable, and it also rebuilds indexes and removes bloat. Downtime grows with database size.

# stop writes: maintenance mode, stop Gunicorn and Celery
pg_dump --format=custom --no-owner --dbname="postgresql://myapp@old-db:5432/myapp" --file=myapp.dump
pg_restore --jobs=4 --no-owner --role=myapp --dbname="postgresql://postgres@new-db:5432/myapp" myapp.dump
  • Use the new version's pg_dump binary where possible; newer clients can dump older servers.
  • --jobs parallelises the restore across tables and indexes.
  • Create roles and extensions on the new server first (pg_dumpall --roles-only exports roles).

4. Method B: pg_upgrade

pg_upgrade converts the cluster's catalog to the new version and reuses the data files. With --link, files are hard-linked instead of copied, so even large databases upgrade in minutes.

On Debian and Ubuntu, the pg_upgradecluster wrapper handles configuration and ports:

sudo apt install postgresql-17 postgresql-17-postgis-3   # plus extension packages you need
sudo systemctl stop gunicorn celery-worker celery-beat
sudo pg_dropcluster 17 main --stop                        # remove the empty default cluster of the new version
sudo pg_upgradecluster -m upgrade --link 16 main
sudo pg_lsclusters

The upgraded cluster takes over the old port; the old cluster is moved aside and stopped. With plain pg_upgrade, run pg_upgrade --check first, which reports incompatibilities without changing anything.

Rollback caveat: with --link, once the new cluster has been started, the old cluster can no longer be used safely, because they share data files. Take a backup (or a volume snapshot) immediately before upgrading. Without --link, files are copied, which needs double the disk space but keeps the old cluster intact.

5. Method C: logical replication

For near-zero downtime, replicate from the old server to a new one, then switch:

-- on the old server (wal_level = logical)
CREATE PUBLICATION myapp_pub FOR ALL TABLES;

-- on the new server, after creating the schema with pg_dump --schema-only
CREATE SUBSCRIPTION myapp_sub
  CONNECTION 'host=old-db dbname=myapp user=replicator password=...'
  PUBLICATION myapp_pub;
logical replication cutoverNew cluster, schemapg_dump --schema-onlyPublication +subscriptioninitial copyCaught uplag near zeroStop writes, syncsequencessecondsPoint Django at newDATABASE_URLSequences are not replicated; set them before switchingdjango-deployment.com
Tables need primary keys or replica identity to replicate updates.

When the subscription has caught up, stop writes briefly, confirm it is in sync, update sequences on the new server (logical replication does not copy sequence values), switch Django's DATABASES to the new server, and resume. Tables need primary keys (or a replica identity) for updates and deletes to replicate, which Django models have by default. Schema changes are not replicated, so freeze migrations during the process.

Sequence values can be synchronised with a query per sequence, for example:

SELECT setval('orders_order_id_seq', (SELECT max(id) FROM orders_order));

6. Managed services

  • Amazon RDS: in-place major version upgrade (downtime while it runs; take a snapshot first), or RDS Blue/Green Deployments, which use logical replication for a short switchover.
  • Cloud SQL, Azure Database for PostgreSQL: in-place major version upgrades from the console or CLI, with a backup taken automatically or recommended beforehand.

Always test the same upgrade on a restored copy of the production instance first.

7. After the switch

sudo -u postgres vacuumdb --all --analyze-in-stages
after the switch1ANALYZE all tablesplanner statistics2Update extensionsALTER EXTENSION … UPDATE3Run Django checkscheck, showmigrations4Watch slow queriesplans can change5Keep the old clusteruntil confidentpg_upgrade does not carry statistics: queries are slow until ANALYZEdjango-deployment.com
vacuumdb --analyze-in-stages gets usable statistics quickly.

pg_upgrade does not carry planner statistics over; until ANALYZE runs, query plans can be poor. --analyze-in-stages produces usable statistics quickly, then refines them.

Then:

ALTER EXTENSION pg_trgm UPDATE;
SELECT postgis_extensions_upgrade();   -- if you use PostGIS
python manage.py check --deploy
python manage.py showmigrations | grep "\[ \]" || echo "no unapplied migrations"

Run smoke tests covering logins, writes, search, and background tasks, and watch error rates and slow queries for the next hours.

If the operating system was also upgraded (a new glibc), text indexes using libc collations may need REINDEX, because collation sort order can change between glibc versions. PostgreSQL logs collation version warnings when it detects this.

Verification check:

SELECT version();
SELECT count(*) FROM django_migrations;
SELECT relname, last_analyze, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 5;

Explanation

Major PostgreSQL versions change internal catalogs and sometimes storage details, so data must be migrated rather than reused as-is. Dump and restore rewrites everything logically; pg_upgrade rewrites only the catalog and keeps the data files; logical replication streams row changes into a new cluster while the old one keeps serving. For Django, the database version is mostly invisible, as long as the ORM's supported range includes it, extensions are present, and statistics are refreshed so query plans stay good.

Edge cases / notes

Connection poolers and replicas

Update PgBouncer's target and restart it after switching servers. Physical replicas must be rebuilt for the new major version; they cannot replicate across major versions.

Rollback

With dump and restore or logical replication, the old server stays intact, so rollback is pointing Django back to it (losing writes made on the new one unless you replicate back). With pg_upgrade --link, rollback means restoring the pre-upgrade backup or snapshot.

When to automate this

Script the rehearsal: restore the latest backup to a scratch server, upgrade, run Django's tests and smoke tests against it, and report the timing. Run it before every major upgrade and whenever your database size has grown significantly.

Next steps

FAQ

Which PostgreSQL versions does Django support?

Each Django release supports a range of PostgreSQL versions listed in its documentation; old versions are dropped as they approach end of life. Check before upgrading either component.

What is the fastest way to upgrade PostgreSQL?

pg_upgrade with --link usually completes in minutes regardless of data size, because data files are linked rather than copied.

Can I upgrade PostgreSQL without downtime for Django?

Nearly, with logical replication: replicate to the new version, stop writes briefly, sync sequences, and switch the connection.

Why are queries slow right after a PostgreSQL upgrade?

pg_upgrade does not preserve planner statistics. Run vacuumdb --all --analyze-in-stages immediately after the upgrade.

Keep going