This is the fully manual step-by-step procedure for cross-provider PostgreSQL migration using
pg_dump/pg_restore+ logical replication.For an automated version that handles snapshot coordination, progress monitoring, proxy setup, and table exclusions, see
migrate.shin the root of this repository.
Set these once and reuse throughout:
# Source (old provider)
export SRC_HOST="source-db.oldprovider.com"
export SRC_PORT="5432"
export SRC_DB="myapp"
export SRC_USER="myapp_admin"
# Target (new provider)
export TGT_HOST="target-db.newprovider.com"
export TGT_PORT="5432"
export TGT_DB="myapp"
export TGT_USER="myapp_admin"
# Shorthand connection strings
export SRC="postgresql://${SRC_USER}@${SRC_HOST}:${SRC_PORT}/${SRC_DB}"
export TGT="postgresql://${TGT_USER}@${TGT_HOST}:${TGT_PORT}/${TGT_DB}"psql "$SRC" -c "SELECT version();"
psql "$TGT" -c "SELECT version();"psql "$SRC" -c "SELECT extname, extversion FROM pg_extension ORDER BY extname;"psql "$SRC" -c "SELECT pg_size_pretty(pg_database_size('$SRC_DB'));"psql "$SRC" -c "SHOW wal_level;"If it's not
logical, you'll need to change it and restart the source. Many managed providers let you set this via a parameter group / config panel.ALTER SYSTEM SET wal_level = 'logical'; -- Then restart the PostgreSQL instance
psql "$SRC" -c "SHOW max_replication_slots;"
psql "$SRC" -c "SHOW max_wal_senders;"Ensure both are >= the number of subscriptions you plan to create (at least 1).
Dump roles from source and apply to target. pg_dumpall with --roles-only
pulls global role definitions:
pg_dumpall -h "$SRC_HOST" -p "$SRC_PORT" -U "$SRC_USER" --roles-only > roles.sqlReview and clean up the file (remove provider-specific superuser roles, etc.):
less roles.sql # review before applying
psql "$TGT" -f roles.sqlWe use pg_dump with --format=directory so we can restore in sections:
- pre-data: table definitions, types, sequences — no indexes or constraints
- post-data: indexes, foreign keys, triggers — applied after the data load
This is faster than creating indexes before the load (bulk index creation is much faster than incremental inserts into existing indexes) and avoids FK ordering issues during parallel restore entirely.
pg_dump "$SRC" \
--schema-only \
--no-owner \
--no-privileges \
--format=directory \
--file=schema.dir
--no-ownerand--no-privilegeshelp avoid errors from roles that don't exist yet or differ between providers.Tip: If the source has extensions with large internal catalogs (e.g. TimescaleDB with thousands of chunk tables), add
--schema=publicto limit the dump to your application schema. This can reduce dump time from tens of minutes to seconds.
# For each extension your app uses:
psql "$TGT" -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;"
psql "$TGT" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
# ... etc.pg_restore \
--dbname="$TGT" \
--no-owner \
--no-privileges \
--section=pre-data \
schema.dir 2>&1 | tee schema_import.logThe post-data section (indexes + FKs) will be applied after the data load in Step 5. This makes the restore faster and avoids FK ordering problems during parallel restore.
# Source
psql "$SRC" -c "SELECT schemaname, count(*) FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
GROUP BY schemaname;"
# Target (should match)
psql "$TGT" -c "SELECT schemaname, count(*) FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
GROUP BY schemaname;"The key to a gap-free migration is that the replication slot and the data dump share the same consistent snapshot. The slot will replay WAL starting right after that snapshot, while the dump captures everything up to it — no gap, no overlap, no duplicates.
Why this matters: If the slot and the dump use different snapshots (e.g., creating the slot in one transaction and exporting a snapshot in another), any writes committed between the two points will appear in both the dump and the WAL stream, causing duplicate-key errors when the subscriber tries to apply them.
psql "$SRC" -c "CREATE PUBLICATION migration_pub FOR ALL TABLES;"To replicate only specific tables:
CREATE PUBLICATION migration_pub FOR TABLE users, orders, products;Note: Unlogged tables cannot be added to publications (they don't generate WAL). If you have unlogged tables, use an explicit table list instead of
FOR ALL TABLES. Themigrate.shtool handles this automatically.
The replication slot must be created and the snapshot exported in the
same transaction, and that transaction must use REPEATABLE READ
isolation. That transaction must stay open until pg_dump finishes
using the snapshot.
Why REPEATABLE READ READ ONLY? With the default READ COMMITTED isolation, each statement gets a fresh snapshot. Writes committed between
pg_create_logical_replication_slotandpg_export_snapshot()would appear in both the dump (via the newer snapshot) and the WAL stream (via the slot), causing duplicate-key errors. REPEATABLE READ locks the snapshot at the first statement, ensuring the slot and the exported snapshot share the same consistent point. READ ONLY further prevents accidental writes in this session.
# Terminal 1 — create slot + export snapshot, keep the transaction open
#
# IMPORTANT: run psql interactively (do NOT use a heredoc — it closes
# stdin and psql exits, rolling back the transaction).
psql "$SRC"Then type these commands at the psql prompt:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT pg_create_logical_replication_slot('migration_sub', 'pgoutput');
SELECT pg_export_snapshot();
-- Note the snapshot ID, e.g. '00000003-00000001-1'
-- *** Keep this session open until pg_dump finishes ***# Terminal 2 — run pg_dump using that snapshot
pg_dump "$SRC" \
--data-only \
--format=directory \
--compress=gzip \
--snapshot="00000003-00000001-1" \
--file=data.dirNote on compression:
zstdgives better compression ratio and speed but not allpg_dumpbuilds include zstd support. Usegzipas a safe fallback. Use--compress=0if CPU is the bottleneck and you have disk to spare.
Replace
00000003-00000001-1with the actual snapshot ID from Terminal 1.Once pg_dump completes, go back to Terminal 1 and type
COMMIT;. The replication slot persists independently of the transaction.Do NOT commit before pg_dump finishes — the snapshot is only valid while the exporting transaction is open.
Why directory format?
pg_restore --jobsworks best with directory format, enabling parallel restore across tables. The dump itself is single-threaded when pinned to a snapshot (--snapshotand--jobscannot be combined), but the restore in the next step benefits greatly from parallelism.
The slot prevents WAL cleanup until the subscriber catches up. For large databases where the dump + restore takes hours, WAL can accumulate on the source. Keep an eye on disk usage:
# Check WAL disk usage on source
psql "$SRC" -c "
SELECT
slot_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_wal_bytes,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_name = 'migration_sub';
"Warning: If the source runs low on disk, you may need to abort and drop the slot with
SELECT pg_drop_replication_slot('migration_sub');. Ensure you have enough headroom before starting (estimate: retained WAL ≈ write rate × total dump+restore time).
Since we applied only the pre-data section in Step 3 (tables without indexes or foreign keys), we can safely do a parallel restore with no FK ordering issues and no index maintenance overhead:
pg_restore \
--dbname="$TGT" \
--data-only \
--no-owner \
--no-privileges \
--jobs=4 \
data.dir 2>&1 | tee restore.logAdjust
--jobsto match your available cores. For large databases, 4–8 jobs is a good starting point.
Now apply the post-data section from the schema dump. This builds all indexes from scratch (much faster than incremental inserts) and creates foreign keys in one pass:
pg_restore \
--dbname="$TGT" \
--no-owner \
--no-privileges \
--jobs=4 \
--section=post-data \
schema.dir 2>&1 | tee postdata.logThis is also parallelizable — indexes on different tables are built concurrently.
for table in users orders products; do
echo "=== $table ==="
psql "$SRC" -t -c "SELECT count(*) FROM $table;"
psql "$TGT" -t -c "SELECT count(*) FROM $table;"
doneCounts may differ slightly because writes continued on source during the load. That's expected — replication will catch up the delta.
psql "$TGT" -c "
CREATE SUBSCRIPTION migration_sub
CONNECTION 'host=${SRC_HOST} port=${SRC_PORT} dbname=${SRC_DB} user=${SRC_USER} password=YOUR_PASSWORD'
PUBLICATION migration_pub
WITH (
copy_data = false, -- we already loaded the data
create_slot = false, -- use the slot we created in Step 4b
slot_name = 'migration_sub'
);
"Why
copy_data = falseandcreate_slot = false:
- The bulk load already handled the initial data.
- The replication slot was created before the dump, so all WAL since that point has been retained. The subscription will now replay those accumulated changes and then switch to live streaming.
Connectivity: The
CONNECTIONstring must be reachable from the target database server, not from your workstation. If the target cannot directly reach the source (e.g. source is behind a VPN), you can use a TCP proxy (socat) on a machine that can reach both:# On a machine with access to both source and target socat TCP-LISTEN:5432,fork,reuseaddr TCP:source-host:5432Then use the proxy machine's address in the
CONNECTIONstring.
# On source — check the slot is now active
psql "$SRC" -c "SELECT slot_name, active, restart_lsn FROM pg_replication_slots;"
# On target — check subscription status
psql "$TGT" -c "SELECT subname, subenabled, subconninfo FROM pg_subscription;"
# On target — check replication worker status
psql "$TGT" -c "
SELECT
pid,
subname,
received_lsn,
latest_end_lsn,
latest_end_time
FROM pg_stat_subscription
WHERE subname = 'migration_sub';
"# Run on source to see how far behind the subscriber is
psql "$SRC" -c "
SELECT
slot_name,
confirmed_flush_lsn,
pg_current_wal_lsn(),
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots
WHERE slot_name = 'migration_sub';
"The subscription will first replay the WAL that accumulated during the dump + restore. Once
lag_bytesis consistently near 0, you're ready for cutover.
psql "$SRC" -t -A -c "
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' ORDER BY tablename;
" | while read -r table; do
src_count=$(psql "$SRC" -t -A -c "SELECT count(*) FROM \"$table\";")
tgt_count=$(psql "$TGT" -t -A -c "SELECT count(*) FROM \"$table\";")
if [ "$src_count" != "$tgt_count" ]; then
echo "MISMATCH $table: src=$src_count tgt=$tgt_count"
else
echo "OK $table: $src_count rows"
fi
done# MD5 checksum of entire table (works for small-medium tables)
psql "$SRC" -t -c "SELECT md5(string_agg(t::text, '' ORDER BY id)) FROM users t;"
psql "$TGT" -t -c "SELECT md5(string_agg(t::text, '' ORDER BY id)) FROM users t;"DATABASE_URL="$TGT" ./run_tests.shThis is the critical phase. The downtime is the gap between stopping writes and switching your app to the new database.
# 1. Put app in maintenance mode / stop writes
# (app-specific — e.g., scale down workers, enable maintenance page)
# 2. Wait for final replication catch-up
# Re-run the lag check until lag_bytes = 0
psql "$SRC" -c "
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots
WHERE slot_name LIKE 'migration_sub%';
"
# 3. Do a final row count comparison on critical tables
for table in users orders products; do
echo "=== $table ==="
psql "$SRC" -t -c "SELECT count(*) FROM $table;"
psql "$TGT" -t -c "SELECT count(*) FROM $table;"
done
# 4. Reset sequences on target (logical replication does NOT replicate sequences)
psql "$SRC" -t -A -c "
SELECT 'SELECT setval(''' || sequence_schema || '.' || sequence_name || ''', '
|| last_value || ', true);'
FROM information_schema.sequences s
JOIN pg_sequences ps ON s.sequence_name = ps.sequencename
WHERE sequence_schema = 'public';
" | psql "$TGT"
# 5. Drop the subscription on target (stop replication)
psql "$TGT" -c "ALTER SUBSCRIPTION migration_sub DISABLE;"
psql "$TGT" -c "DROP SUBSCRIPTION migration_sub;"
# 6. Drop the publication on source
psql "$SRC" -c "DROP PUBLICATION migration_pub;"
# 7. Update your app's DATABASE_URL / connection string to point to $TGT
# (DNS update, config change, secret rotation — depends on your setup)
# 8. Bring app back onlineTypical downtime: 30 seconds to 5 minutes, depending on how fast you can verify lag=0, reset sequences, and flip the connection.
psql "$TGT" -c "ANALYZE;"psql "$TGT" -c "REINDEX DATABASE $TGT_DB;"# Active connections
psql "$TGT" -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state;"
# Slow queries (if pg_stat_statements is enabled)
psql "$TGT" -c "
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;
"psql "$SRC" -c "ALTER DATABASE $SRC_DB SET default_transaction_read_only = on;"# Drop the replication slot on source if it wasn't cleaned up
psql "$SRC" -c "SELECT pg_drop_replication_slot('migration_sub');"| Problem | Check |
|---|---|
| Subscription not starting | Source firewall / security group allows target IP on port 5432 |
wal_level not logical |
Requires source restart after changing; some managed providers need a support ticket |
| Replication lag not decreasing | Check max_wal_senders, network throughput, target disk I/O |
| Permission denied on source | Replication user needs REPLICATION attribute + SELECT on published tables |
| Sequences out of sync after cutover | Re-run the sequence reset script from Step 7.4 |
| Missing data in some tables | Verify those tables are included in the publication |
| Source disk filling up during load | WAL retained by the slot is accumulating; check with pg_replication_slots query from Step 4c. Speed up restore or increase disk |
| Slot dropped accidentally before subscribe | Data gap is possible; restart from Step 4b and redo the dump |
| Duplicate key errors on subscriber | Slot and dump used different snapshots — writes between the two appear in both. Drop subscription, drop slot, truncate target, and redo Step 4 with slot + snapshot in the same transaction |
| FK constraint errors during parallel restore | Restore pre-data section only (Step 3), load data without indexes/FKs, then apply post-data section after (Step 5b) |
cannot add relation to publication |
Table is unlogged — use explicit table list instead of FOR ALL TABLES |
pg_dump hangs on catalog queries |
Source has large internal catalogs (e.g. TimescaleDB chunks) — add --schema=public |
| Subscription cannot resolve source hostname | Target can't reach source directly — use a TCP proxy (socat) on an intermediary machine |
invalid compression specification: ZSTD |
pg_dump build lacks zstd support — use --compress=gzip instead |