How to migrate a Postgres database with minimal downtime
Moving a Postgres database between providers has two techniques. Dump and restore: stop writes, copy everything, point the app at the new database. Logical replication: stream changes to the new database while the old one keeps serving, then cut over in seconds.
The choice is one calculation — how long a full restore of your data takes versus how much downtime you can accept. Everything else in this post is preparation, because the ways these migrations fail are almost all preventable and almost all discovered at the worst moment.
First: measure, don't estimate
Do a full dump and restore into a scratch database on the destination, with your real data, and time it. Not a subset. The number you get is the downtime for the simple approach, and it's usually larger than people guess because index rebuilds dominate at larger sizes.
# Time the whole thing end to end, with real data
time pg_dump --format=custom --no-owner --no-acl \
"$OLD_DATABASE_URL" -f prod.dump
time pg_restore --no-owner --no-acl --jobs=4 \
--dbname="$NEW_DATABASE_URL" prod.dump
# --jobs parallelises index and constraint creation, which is where
# most of the restore time goes on any non-trivial databaseIf the total fits your acceptable window with margin, take the simple path. If it doesn't, use logical replication. Don't try to optimise a dump-and-restore into being fast enough — that path ends with a migration that's 90% done when the window closes.
The preflight checks that prevent a failed window
- Major version parity. Restore into the same major version. Combining a provider migration with a version upgrade is two risky changes at once, and if something breaks you won't know which one did it.
- Extensions. List them on the source and confirm every one exists on the destination, at a compatible version. A restore that fails on a missing extension halfway through is the classic wasted window.
- Roles and ownership. Dump with --no-owner and --no-acl, then create the roles you need on the destination deliberately. Managed providers don't give you superuser and a dump full of ownership statements will fail.
- Sequences. After any restore, verify sequence values. A sequence left behind the max id of its table produces duplicate key errors on the first insert, minutes after cutover, and it's a confusing thing to debug under pressure.
- Connection limits. Check the destination's limit and whether a pooler is in front. A migration is a fine time to discover you needed one; a Monday morning is not.
-- Run on the source, then verify each one exists on the destination
SELECT extname, extversion FROM pg_extension ORDER BY 1;
-- After restore: sequences behind their table are a live bug
SELECT schemaname, sequencename, last_value
FROM pg_sequences
WHERE schemaname = 'public'
ORDER BY 1, 2;Path A: dump and restore
For databases where the measured restore fits your window. The sequence:
- Put the application into maintenance mode, or stop the writers. Reads can continue against the old database if you're careful about what that implies.
- Take the dump. Custom format, no owner, no ACLs.
- Restore with parallel jobs into the destination.
- Verify: row counts on your largest tables, sequence values, extension presence, and one real query from the application.
- Switch the connection string and restart the app.
- Keep the old database running and readable for at least a week.
Path B: logical replication
For databases where a restore takes hours and you need the window measured in seconds. The destination subscribes to the source, copies the initial data, then streams changes continuously. When replication lag is near zero, you pause writes for a few seconds and switch.
-- On the source: publish the tables you're moving
CREATE PUBLICATION migration FOR ALL TABLES;
-- On the destination, after creating the schema (dump with --schema-only)
CREATE SUBSCRIPTION migration
CONNECTION 'host=old-db.example.com dbname=app user=replicator sslmode=require'
PUBLICATION migration;
-- Watch the lag until it is consistently near zero
SELECT slot_name,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag
FROM pg_replication_slots;Three things about logical replication that catch people out. It does not replicate schema changes, so freeze migrations for the duration. It does not replicate sequence values, so you must advance them on the destination at cutover. And every table needs a replica identity — a primary key, in practice — or updates and deletes won't replicate, silently.
The cutover itself
- Stop writes at the application — a maintenance flag is cleaner than killing connections.
- Wait for replication lag to reach zero. Confirm it, don't assume it.
- Advance every sequence on the destination past its table's maximum id.
- Point the application at the new database and restart.
- Run a real write and a real read through the application, immediately.
- Drop the subscription on the destination, so it stops trying to replicate from a database you're about to retire.
Afterwards
Run ANALYZE on the new database. A freshly restored database has no statistics, and the planner will make poor choices until it does — which presents as 'the new provider is slow' and is nothing of the sort. Then take a backup on the new provider and restore it once, so you know that path works before you need it.
Keep the old database running for a week. Not snapshotted — running, reachable, and able to serve. The cost of a week of an instance you don't need is trivially less than the cost of discovering on day three that something still points at it.
Frequently asked questions
Should I use pg_dump or logical replication to migrate Postgres?
Measure a full dump and restore into the destination with your real data, and compare that time against the downtime you can accept. If it fits with margin, use dump and restore — it is simpler, has fewer failure modes, and does not require the source to support replication. If it does not fit, use logical replication, which copies the initial data and then streams changes so the actual cutover takes seconds. Do not try to optimise a dump and restore into fitting a window it does not fit; that path ends with a migration that is ninety percent complete when the window closes and no good options.
Why do I get duplicate key errors right after a Postgres migration?
The sequences were not advanced. A restore populates table data but can leave a sequence's last value behind the maximum id already present, so the first few inserts after cutover reuse ids that exist and fail on the primary key. Logical replication has the same issue by design — it does not replicate sequence state at all. Check pg_sequences against the maximum id of each table before you send traffic, and advance any that are behind with setval. It is a two-minute check that prevents an error appearing minutes after cutover, when everyone is already tense and looking in the wrong place.
What does logical replication not replicate?
Three things that matter. Schema changes are not replicated, so you must freeze migrations for the duration of the copy or apply them manually on both sides in the correct order. Sequence values are not replicated, so they must be advanced on the destination at cutover. And tables without a replica identity — in practice, without a primary key — replicate their inserts but silently drop updates and deletes, which produces a destination that looks correct on row count and is quietly wrong. Audit for tables lacking a primary key before you begin, since discovering this by comparing data after cutover is a very expensive way to learn it.
Why is my database slow immediately after migrating?
Usually missing statistics rather than the new provider. A freshly restored database has no planner statistics, so Postgres makes poor choices about join order and index usage until it collects them — queries that took milliseconds can take seconds, and it looks exactly like an infrastructure problem. Run ANALYZE across the database as the first thing you do after a restore. If it is still slow afterwards, the next candidates are a cold page cache that will warm on its own, a smaller instance than the source, or a connection pooler configured differently, in roughly that order of likelihood.
How long should I keep the old database after migrating?
At least a week, and genuinely running rather than snapshotted. Something almost always still points at it — a scheduled report, a monitoring integration, a colleague's local environment, a service you forgot deploys from a different repository — and the way you find out is that it starts failing. A running old database turns that discovery into a small fix, while a deleted one turns it into an incident. Watch its connection count during that week: when nothing has connected for several days, you have empirical evidence that the migration is complete, which is better than believing it is.
Keep reading
- Managed Postgres on PandaStack — point-in-time clones make restore drills cheap
- The best managed Postgres providers in 2026
- Postgres backups: RPO and RTO explained
- Zero-downtime schema migrations on deploy
- The best Neon alternatives in 2026
49ms p50 cold start. Fork, snapshot, and scale to zero.