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.

Moving with logical replicationCopy schemapg_dump --schema-onlySubscribeinitial copy + streamStay in syncwhile app runsCutoverseconds of downtimeReplication keeps the new database current, so downtime shrinks to the final switch.
  1. On the source, set wal_level = logical (restart required; on managed services, enable logical replication in parameters).
  2. Copy the schema to the target with pg_dump --schema-only and restore it (or run Django migrations against the empty target).
  3. On the source: CREATE PUBLICATION django_pub FOR ALL TABLES;
  4. On the target: CREATE SUBSCRIPTION django_sub CONNECTION '...' PUBLICATION django_pub; and wait for the initial copy.
  5. At cutover: stop writes, wait for replication lag to reach zero, sync sequences, point Django at the target, resume.
  6. 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.conf or security groups) and have wal_level = logical, enough max_replication_slots and max_wal_senders.
  • A role with REPLICATION privilege (or the provider's equivalent, such as rds_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;
the cutover windowMaintenance onwrites stopLag reaches zerosecondsSequences syncedsetval on targetSettings point totargetrestart appMaintenance offold DB keptThe data is already there; cutover only waits for the last changesdjango-deployment.com
Rehearse the cutover on staging and time each step.

4. Cut over

  1. Put Django in maintenance mode or stop web and Celery processes so no writes reach the source.
  2. Wait until the replication lag is zero and all tables are in state r.
  3. 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)).

  1. Update DATABASE_URL to the target and restart Gunicorn, Celery, and Beat.
  2. Smoke test: log in, create an object (new IDs must not collide), run python manage.py migrate --check.
  3. 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.

moving a database with logical replicationwal_level logical, slots availablesource configuredSchema copied, migrations frozenDDL is not replicatedAll tables have primary keysor replica identitySequences synced at cutovernot replicatedSubscription and slot removed afteror WAL fills the diskSchema changes and sequences are the two things replication does not carrydjango-deployment.com
Compare row counts for key tables before and after cutover.

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

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.

Keep going