Move a Django Database With Minimal Downtime Using Logical Replication
Migrate a Django PostgreSQL database to a new server, provider, or major version with seconds of downtime: logical replication publications and subscriptions, schema copy, sequence sync, cutover steps, and verification.
Problem statement
Moving a large Django database with pg_dump and pg_restore means stopping writes for as long as the dump and restore take: minutes for small databases, hours for hundreds of gigabytes. PostgreSQL's built-in logical replication streams row changes from the old server to the new one while the app keeps running, so the new database stays seconds behind. The cutover then takes only as long as stopping writes, letting replication catch up, syncing sequences, and pointing Django at the new host.
- On the source, set
wal_level = logical(restart required; on managed services, enable logical replication in parameters). - Copy the schema to the target with
pg_dump --schema-onlyand restore it (or run Django migrations against the empty target). - On the source:
CREATE PUBLICATION django_pub FOR ALL TABLES; - On the target:
CREATE SUBSCRIPTION django_sub CONNECTION '...' PUBLICATION django_pub;and wait for the initial copy. - At cutover: stop writes, wait for replication lag to reach zero, sync sequences, point Django at the target, resume.
- Drop the subscription afterwards and keep the old server as a fallback for a while.
Step-by-step solution
1. Prerequisites
- Every replicated table needs a primary key or a replica identity (Django tables have primary keys; check third-party tables).
- The source must allow replication connections from the target (
pg_hba.confor security groups) and havewal_level = logical, enoughmax_replication_slotsandmax_wal_senders. - A role with
REPLICATIONprivilege (or the provider's equivalent, such asrds_replication). - Logical replication works across major versions, which makes it a common upgrade path (for example 13 to 17).
2. Create the schema on the target
pg_dump --schema-only --no-owner -d "$SOURCE_URL" > schema.sql
psql -d "$TARGET_URL" -f schema.sql
Logical replication copies data, not schema changes. Freeze migrations during the move: deploying a migration on the source would not reach the target.
3. Publish and subscribe
-- on the source
CREATE PUBLICATION django_pub FOR ALL TABLES;
-- on the target
CREATE SUBSCRIPTION django_sub
CONNECTION 'host=old-db.internal dbname=app user=replicator password=... sslmode=require'
PUBLICATION django_pub;
The subscription first copies existing data table by table, then streams ongoing changes. Monitor progress:
-- target: per-table state ('r' = ready)
SELECT srrelid::regclass, srsubstate FROM pg_subscription_rel;
-- source: lag in bytes
SELECT slot_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag
FROM pg_replication_slots;
4. Cut over
- Put Django in maintenance mode or stop web and Celery processes so no writes reach the source.
- Wait until the replication lag is zero and all tables are in state
r. - Sync sequences, which logical replication does not replicate:
-- generate on the source, run on the target
SELECT format('SELECT setval(%L, %s, true);', seq.oid::regclass, s.last_value)
FROM pg_sequences s JOIN pg_class seq ON seq.relname = s.sequencename
WHERE s.last_value IS NOT NULL;
(or run Django's sqlsequencereset against the target, which sets each sequence to MAX(id)).
- Update
DATABASE_URLto the target and restart Gunicorn, Celery, and Beat. - Smoke test: log in, create an object (new IDs must not collide), run
python manage.py migrate --check. - Take the app out of maintenance mode.
5. Clean up
On the target, DROP SUBSCRIPTION django_sub; (this also drops the slot on the source if reachable). Keep the source read-only for a few days as a fallback, then decommission it. A forgotten replication slot on the source retains WAL and fills its disk, so make sure the slot is gone.
Verification check: before cutover, row counts on source and target match for the main tables; after cutover, new objects get IDs above the old maximum, pg_replication_slots on the source is empty, and the app runs against the target with no errors.
Explanation
Logical replication decodes committed changes from the source's write-ahead log into row-level operations and applies them on the subscriber. Unlike physical replication, it works between different major versions and different hosting providers, and the target is a fully writable database. It replicates table data only: schema changes and sequence values must be handled separately. Because the target stays nearly current while the app runs, the planned downtime covers only the moment of switching, which makes it the standard technique for low-downtime moves and major upgrades.
Edge cases / notes
- Large objects (
pg_largeobject) are not replicated; Django rarely uses them. - Initial copy load on the source can be significant for big databases; run it at quiet times.
- Managed sources may need parameter changes and a reboot to enable logical decoding; plan that maintenance first.
- Tools like AWS DMS or pglogical offer similar capabilities with more automation.
Next steps
- The full server move: Move a Django Site to a New Server.
- Version upgrades: Upgrade PostgreSQL Major Version for Django.
FAQ
Can I move a PostgreSQL database without downtime?
Nearly: logical replication reduces downtime to the short cutover while writes are paused.
Does logical replication copy schema changes?
No. Copy the schema first and freeze migrations until after cutover.
Why do inserts fail with duplicate keys after cutover?
Sequences are not replicated. Sync them on the target before resuming writes.
Can logical replication upgrade PostgreSQL versions?
Yes. It works between major versions, which makes it a common upgrade method.