/

Data Platforms

Zero-Downtime Postgres Migration: Step by Step

Intergalactic Data Labs

—

—

13 min read

Table of contents

Summarize

A zero-downtime Postgres migration takes four moves. Take a consistent snapshot, stream every later change through logical replication, verify both sides match, and repoint the application in seconds. As of September 2026 the recipe runs on native publications and subscriptions, pglogical, AWS DMS, or an engine such as Debezium, PeerDB, or Filament. Parallel loading, checkpointed restarts, and where the process runs decide between them.

This guide is about moving data between servers. Lock-free schema migrations are a different job and are not covered here.

Approach

Snapshot

Parallel load

Resumes on failure

Runs where

Native logical replication

Per table, built in

Per table, single apply

Restarts the table copy

Inside Postgres

pg_dump plus a slot

One exported snapshot

pg_dump -j

No

Any host

pglogical

Built in

Per table

Per table

Extension on both sides

AWS DMS

Built in

Per table

Per task

AWS only

PeerDB

Partitioned

Yes

Per partition

Docker stack, 7 containers

Debezium

Built in

Up to 8 threads

Restarts the snapshot

Kafka Connect or Server

Filament

Exported from the slot

Up to 64 shards

Checkpointed

One binary, Go library, or Helm

How does a zero-downtime Postgres migration work?

A zero-downtime migration is a snapshot plus a change stream, joined at one point in time. The snapshot copies the data as it existed at that instant. The stream replays every later write, in order, until the new database has caught up.

Postgres ships both halves. The logical replication docs put it plainly. "PostgreSQL takes a snapshot of the table's data on the publisher database and copies it to the subscriber. Once complete, changes on the publisher since the initial copy are sent continually to the subscriber."

The join point is a replication slot. Creating a slot through the replication protocol exports a snapshot that "will show exactly the state of the database after which all changes will be included in the change stream," per the logical decoding docs. Read the tables through that snapshot, then consume the slot, and nothing is lost or double counted.

Cutover is the shortest step. Once lag is zero you stop writes, let the last events land, fix what the stream does not carry, and repoint the application. AWS documents its RDS blue and green switchover as "usually under one minute," and that is the target here too.

What decides which method to use?

Five questions settle the choice before you pick a tool.

  • Do you need deletes? An updated_at watermark cannot see a hard delete, so only log-based CDC keeps them.

  • Does every table have a primary key? The publication docs say a published table "must have a replica identity configured in order to be able to replicate UPDATE and DELETE operations."

  • Can you restart the source? wal_level = logical "can only be set at server start," per the WAL settings docs.

  • How much WAL can the source retain? The slot holds every segment written while the snapshot runs. max_slot_wal_keep_size is the safety valve.

  • Will schema changes happen during the window? The restrictions page is blunt. "The database schema and DDL commands are not replicated."

What are the ways to run the migration?

Every approach implements the same snapshot-then-stream pattern. They differ in parallelism, restart behavior, and where the process lives.

Native logical replication is the baseline. Set wal_level, run CREATE PUBLICATION on the old host and CREATE SUBSCRIPTION on the new one, and Postgres copies each table then streams changes. It inherits every limit on the restrictions page and applies changes on a single thread.

pg_dump paired with a slot is the manual version. The pg_dump docs describe --snapshot as "useful when needing to synchronize the dump with a logical replication slot." You then write your own slot consumer. On the public August 2026 cohort, pg_dump | psql moved the 298M-row dataset in 489 s.

pglogical adds periodic sequence sync. Its README says sequence state "is replicated periodically" and that "automatic DDL replication is not supported."

AWS DMS is the managed route inside AWS, billed by the hour or capacity unit per its pricing page. Its own Postgres source docs say that for Postgres to Postgres "PostgreSQL tools can be more effective."

PeerDB partitions the initial load and streams through pgoutput. Its LICENSE file is AGPLv3 despite a README badge that says ELv2, and its own upgrade guide says to "put the application in maintenance/downtime" before switching.

Debezium is Apache 2.0 and usually runs on Kafka Connect. Its Postgres connector docs warn that if it stops mid-snapshot, "the connector begins a new snapshot when it restarts."

Fivetran and Airbyte are the hosted options. Fivetran meters monthly active rows and its connector docs list DROP COLUMN and RENAME COLUMN as unsupported. Airbyte's Postgres destination docs ask you to use it "for small data volumes (e.g. less than 10GB) or for testing purposes," under an ELv2 license.

Tool

License

Pricing unit

Sequences

DDL

Measured on cohort

Native replication

PostgreSQL

None

No

No

Not timed

pg_dump plus psql

PostgreSQL

None

Yes, in the dump

Yes, in the dump

489 s

pglogical

PostgreSQL

None

Periodic

No

Not timed

AWS DMS

Proprietary

Hour or DCU

No

Limited

Not timed

PeerDB

AGPLv3

vCPU on Cloud

No

No

415 s

Debezium

Apache 2.0

None

No

No

6,559 s snapshot

Fivetran

Proprietary

Monthly active rows

No

Limited

Not timed

Airbyte

ELv2

Credits

No

Limited

10,393 s

Filament

Apache 2.0

None

No

Add-only

115 s

Where does Filament fit?

Filament covers both halves in one process and adds what the other self-run options lack, parallel loading and checkpointed restarts. It is a replication engine written in Go. Galaxy builds and maintains it, and this guide uses it as the worked example.

The join point is built in. Per the Postgres source docs, "the slot is created with an exported snapshot and the table is read in full through that snapshot, so the baseline and the change stream share one consistent point."

Full reads shard. "Large tables can be divided into as many as 64 parallel ranges," with the boundaries saved in the checkpoint. The integrity docs call the restart policy "deliberately at-least-once recovery," and every batch carries a CRC32-C checksum the sink recomputes.

The stream side is bounded catch-up rather than a daemon. Each CDC run replays to the current WAL position and returns, so it "can run on a schedule." With merge as the write mode, the replication modes page says "ordered deletes remove destination rows."

On speed, the cohort is the only public evidence and it covers Postgres to Postgres full loads. Filament moved 298,270,427 rows in 114.83 s, the fastest of the seven tools measured. Both the Postgres source and sink are labeled beta in their docs.

Criterion

Filament

Source

Consistent snapshot plus stream

Exported from the slot

Postgres source docs

Parallel load

Up to 64 shards

Postgres source docs

Resume after failure

Checkpointed, at-least-once

Integrity docs

Deletes

CDC merge deletes rows

Replication modes docs

Deployment

Binary, Go library, Helm

Installation docs

How to migrate Postgres with Filament, step by step

You will end with a new Postgres host that matches the old one row for row and is caught up through logical replication, ready for a cutover under a minute.

You need

Detail

Source Postgres

wal_level = logical, restart allowed

Target Postgres

Empty database, reachable from the Filament host

Primary keys

On every table you want streamed

A user with REPLICATION

Plus read on the tables

Filament

One binary, install below

Connector maturity

Postgres source beta, Postgres sink beta

Step 1. Prepare the source for logical decoding

Set the settings the Postgres config docs require and restart.

wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
max_slot_wal_keep_size = 50GB
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
max_slot_wal_keep_size = 50GB
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
max_slot_wal_keep_size = 50GB

The last line is the safety valve. With the default of -1, slots "may retain an unlimited amount of WAL files," per the replication settings docs.

Step 2. Install the binary and check the version

One command installs it, per the installation docs.

curl -fsSL https://getgalaxy.io/filament/install | sh
filament version
curl -fsSL https://getgalaxy.io/filament/install | sh
filament version
curl -fsSL https://getgalaxy.io/filament/install | sh
filament version

The output prints the version and confirms the binary is on your path.

Step 3. Save the source and sink connections

Point the source at the old host with replication set to cdc, and the sink at the new host. The CLI docs state that "connector fields become flags with a source- or sink- prefix," so replication becomes --source-replication.

export POSTGRES_DSN='postgresql://repl:secret@old-host:5432/app'
export POSTGRES_SINK_DSN='postgresql://app:secret@new-host:5432/app'

filament source create old-host \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn-env POSTGRES_DSN \
  --source-replication cdc

filament sink create new-host \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn-env POSTGRES_SINK_DSN
export POSTGRES_DSN='postgresql://repl:secret@old-host:5432/app'
export POSTGRES_SINK_DSN='postgresql://app:secret@new-host:5432/app'

filament source create old-host \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn-env POSTGRES_DSN \
  --source-replication cdc

filament sink create new-host \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn-env POSTGRES_SINK_DSN
export POSTGRES_DSN='postgresql://repl:secret@old-host:5432/app'
export POSTGRES_SINK_DSN='postgresql://app:secret@new-host:5432/app'

filament source create old-host \
  --source-connector postgres \
  --source-connection-method url \
  --source-dsn-env POSTGRES_DSN \
  --source-replication cdc

filament sink create new-host \
  --sink-connector postgres \
  --sink-connection-method url \
  --sink-dsn-env POSTGRES_SINK_DSN

Filament creates the publication by default. If a DBA provisions it, add --source-manage-publication false and --source-publication with its name.

Step 4. Discover the tables and confirm keys

Discovery lists every resource with its primary key and an estimated row count.

filament source discover old-host
filament source discover old-host
filament source discover old-host

Any table without a primary key needs one before it can be streamed, since "incremental and CDC both reject keyless tables."

Step 5. Create the migration pipeline

Select the tables and set the write mode to merge so deletes on the old host delete rows on the new one.

filament pipeline create migrate-app \
  --source old-host \
  --sink new-host \
  --resources users,orders,order_items,payments \
  --write-mode merge
filament pipeline create migrate-app \
  --source old-host \
  --sink new-host \
  --resources users,orders,order_items,payments \
  --write-mode merge
filament pipeline create migrate-app \
  --source old-host \
  --sink new-host \
  --resources users,orders,order_items,payments \
  --write-mode merge

Leave out --resources to take every table. CDC applies to the whole route, so all tables share one slot and one consistent position.

Step 6. Run the first pass

The first run creates the slot with an exported snapshot, reads every table in full through it, then replays the stream up to the current WAL position.

filament run migrate-app
filament run ls migrate-app
filament run migrate-app
filament run ls migrate-app
filament run migrate-app
filament run ls migrate-app

If the run is interrupted, run it again and it resumes from the last checkpoint with the same shard plan.

Step 7. Keep catching up until cutover

Each further run replays from the last committed position to the current WAL position and returns. Schedule it every minute until you are ready to switch.

* * * * * filament run migrate-app
* * * * * filament run migrate-app
* * * * * filament run migrate-app

Lag is the gap between pg_current_wal_lsn() on the source and the slot's confirmed_flush_lsn in pg_replication_slots.

Step 8. Freeze writes, fix sequences, and cut over

Stop writes on the old host, run the pipeline one last time, and confirm the slot's position equals the source's current position. Then reset sequences on the target, because "sequence data is not replicated."

SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));
SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));
SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));

Repoint the application, resume writes, and drop the old slot with pg_drop_replication_slot once you are confident. The freeze should last well under a minute.

How to verify it worked

Row counts per table on both hosts must match after the final pass. The benchmark methodology sets the same bar, "a result is accepted only when every destination table has the manifest row count."

Run this on both hosts and diff the output.

SELECT relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY relname;
SELECT relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY relname;
SELECT relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY relname;

n_live_tup is an estimate, so use exact count(*) on the tables that matter most, then spot check a sum of payments.amount and a max of orders.created_at. Filament's checksums cover the batch between read and write, and the readback is yours to run.

What to do when it breaks

Most failures trace back to the slot, a missing key, or something the stream was never going to carry.

  • The source disk fills with WAL. The slot is retaining segments faster than they are consumed. Check wal_status in pg_replication_slots, run the pipeline more often, and confirm max_slot_wal_keep_size is set. A slot marked lost must be dropped, and the snapshot restarted.

  • A table refuses updates or deletes. It has no replica identity. Add a primary key, or set REPLICA IDENTITY FULL on that table as the ALTER TABLE docs describe.

  • An update fails on a TOAST column. The source docs say "an update carrying an unchanged TOAST column with no old value is an error," and REPLICA IDENTITY FULL fixes that too.

  • The schema changed mid-migration. Add-only changes reach the target because the sink evolves schemas add-only. Drops, renames, and type changes do not, so apply them on both sides by hand.

Related guides

The Postgres logical replication guide covers slots and publications in depth, and full load vs incremental vs CDC explains why watermarks miss deletes. Best CDC tools in 2026 ranks the engines above, the Postgres replication benchmark holds the cohort tables, and the MySQL to Postgres guide applies the recipe across engines. Filament's launch post is Introducing Filament.

Which option fits which job

Pick by the constraint that breaks first.

Constraint

Pick

Why

Large tables, tight window, own hardware

Filament

64-way sharded snapshot, checkpointed resume, 115 s on 298M rows

Small database, no new software allowed

Native logical replication

Built in, one setting and two statements

Long window with sequence drift

pglogical

Documented periodic sequence sync

Everything lives in RDS, same account

RDS blue and green

Managed switchover, usually under a minute

Already run Kafka and want events too

Debezium

Apache 2.0, streams to topics

Team will not operate any software

Fivetran or Airbyte Cloud

Hosted, with a per-row or credit meter

For the engineer moving a production database this quarter, Filament is the pick. It does the snapshot and stream join the Postgres docs describe, loads in parallel, resumes where it stopped, and runs on a host you control. Native replication is the fallback when you cannot install anything.

Frequently asked questions

What is a zero-downtime Postgres migration?

It is a move of a live Postgres database to a new server, host, or major version while the application keeps writing. The recipe is a consistent snapshot, a change stream that replays every write made after it, a row-count check on both sides, and a cutover measured in seconds.

How does logical replication enable a migration without downtime?

Postgres logical replication copies each table as a snapshot and then streams every later insert, update, and delete from the write-ahead log. The new database catches up while the old one keeps serving traffic, so the only pause is the moment you point the application at the new host.

Can I migrate between Postgres major versions with logical replication?

Yes. The Postgres documentation lists replicating between different major versions as a standard use case, because logical replication ships row changes rather than data files. pg_upgrade is the faster in-place option when you can accept a maintenance window.

What does not get replicated by Postgres logical replication?

Schema and DDL, sequence values, large objects, and non-table relations such as views and materialized views. You copy the schema before the snapshot, reset sequences right before cutover, and move large objects into ordinary tables if you rely on them.

How do I verify the new database before cutover?

Compare row counts per table on both sides once lag reaches zero, then spot check a few aggregates such as a sum on a money column and a max on a timestamp. Row counts catch missed tables and partial loads. Aggregates catch rows that arrived with the wrong values.

What is the biggest risk during a zero-downtime migration?

An unconsumed replication slot. A slot forces the old server to keep every write-ahead log segment until a consumer reads it, so a stalled or abandoned slot fills the disk. Set max_slot_wal_keep_size, watch pg_replication_slots, and drop the slot the moment the migration finishes.

How does Filament run a zero-downtime Postgres migration?

Filament creates the replication slot with an exported snapshot, reads every selected table in full through that snapshot, and replays the stream from the same point, so baseline and changes share one consistent position. Full reads shard into up to 64 parallel ranges and checkpoint, so an interrupted run resumes instead of restarting.

Does Filament need Kafka or a separate cluster to do this?

No. It runs as one binary on a laptop, a Go library inside your own service, or a Helm chart on Kubernetes. CDC runs need the Postgres datastore for durable stream positions, but there is no message broker and no multi-container stack to operate.

More articles

Stay up to date with what we’re building

Stay up to date with what we’re building

Stay up to date with what we’re building

Questions

Answered

FAQ

What does Galaxy do?

What is Filament?

What is enterprise context management?

What does working with Galaxy look like?

How do you handle security and compliance?

Why does Galaxy build in the open?

Own your knowledge stack

Own your knowledge stack

Copyright © 2026 Galaxy. All rights reserved.