Friday, September 11, 2026

Migrating PostgreSQL to Amazon RDS with self-managed logical replication

A tested, step-by-step walkthrough — how PostgreSQL logical replication works internally, and the four traps that catch people at cutover.


Why use PostgreSQL's own replication?

PostgreSQL has had built-in logical replication since version 10. It streams committed row changes from a publisher to a subscriber over a normal database connection, and it works perfectly well when the subscriber is Amazon RDS for PostgreSQL or Aurora PostgreSQL.

For PostgreSQL-to-PostgreSQL moves this is worth considering first. The AWS documentation makes the same point:

"When you migrate from a database engine other than PostgreSQL to a PostgreSQL database, AWS DMS is almost always the best migration tool to use. But when you are migrating from a PostgreSQL database to a PostgreSQL database, PostgreSQL tools can be more effective."

What you get:

  • Near-zero downtime. The bulk copy and the change stream both run while the source stays fully available. Your only outage is the cutover itself — seconds, if you drive it well.
  • Native fidelity. No intermediate type system. PostgreSQL-specific types, collations, and extensions arrive as themselves.
  • No extra moving parts. No service in your data path, no IAM roles, no additional cost.

What you take on: schema, sequences, roles, monitoring, and validation are all yours. That is the whole trade, and the rest of this post is about doing your half properly.

There is a section at the end on when a managed service is the better call.

Everything below was executed, not sketched. I ran it against two PostgreSQL 18.6 clusters (source on port 5501, target on 5502) with a 26,000-row schema, live write traffic during the migration, and a full cutover. The output shown is real. The RDS-specific configuration step (step 2) was validated separately against a live RDS for PostgreSQL 15.19 instance. I verified the replica-identity behaviour on both PostgreSQL 15.19 and 18.6 — it is not version-specific.


How PostgreSQL logical replication actually works

Understanding the mechanism makes the traps obvious rather than surprising. This is all standard, documented PostgreSQL behaviour — you can watch it yourself with log_replication_commands = on.

SOURCE (publisher)                          TARGET (subscriber)
──────────────────                          ───────────────────
wal_level = logical                         wal_level = logical  ← yes, the target too
CREATE PUBLICATION ────────────────────────► CREATE SUBSCRIPTION
                                                     │
                    ◄────────────────────────────────┤  subscriber connects to publisher
                                                     │
  for each table, the subscriber:                    │
   1. creates a temporary replication slot      ◄────┤  SNAPSHOT 'use'
   2. COPY <table> TO STDOUT                    ◄────┤  ← this is where data moves
   3. streams changes from the snapshot's LSN   ◄────┤  catch up writes during the copy
   4. drops the temporary slot                  ◄────┤
                                                     │
  one durable slot streams ongoing changes  ◄────────┘  pgoutput

Three consequences drive everything else:

The subscriber connects to the publisher, not the other way round. Your RDS instance needs network reachability to the source on port 5432, and the source credentials are stored inside the target's pg_subscription catalog. Plan security groups and credential rotation accordingly.

Slot creation yields a snapshot and its LSN atomically. When PostgreSQL creates a logical slot it must find a consistent point — an LSN where every in-flight transaction has committed or aborted. With SNAPSHOT 'use', the COPY then runs inside exactly that snapshot, and streaming resumes from that same LSN. This is what makes the handoff from bulk copy to change streaming lossless: no gap, no duplication. You do not have to engineer it, but you should know it is what you are relying on.

Peak slot usage is 1 + N, not 1. During initial sync PostgreSQL creates one transient slot per table being synchronised (named pg_<suboid>_sync_<relid>_<sysid>), alongside the durable slot for ongoing changes. Size max_replication_slots for the peak, not the steady state.


Prerequisites

Requirement
SourcePostgreSQL 10+ (self-managed on EC2, on-premises, or another cloud)
TargetRDS for PostgreSQL or Aurora PostgreSQL, same or higher major version
NetworkTarget must reach source on 5432. Source must reach target on 5432.
PrivilegesA source role with REPLICATION and SELECT; on the target, rds_superuser

Step 0 — Audit the source before you touch anything

This is the single highest-value five minutes of the whole project. Find every table without a primary key.

SELECT n.nspname || '.' || c.relname AS table_without_pk
  FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
 WHERE c.relkind = 'r'
   AND n.nspname NOT IN ('pg_catalog', 'information_schema')
   AND NOT EXISTS (
         SELECT 1 FROM pg_index i
          WHERE i.indrelid = c.oid AND i.indisprimary);

On my test database:

 table_without_pk
------------------
 public.audit_log

Also inventory what logical replication will not bring, because you will need to handle each one:

-- sequences: never replicated, must be synced at cutover
SELECT schemaname, sequencename, last_value FROM pg_sequences;

-- large objects: invisible to logical replication entirely
SELECT count(*) FROM pg_largeobject_metadata;

-- extensions: confirm each is supported on RDS/Aurora
SELECT extname, extversion FROM pg_extension;

Step 1 — Configure the source

# postgresql.conf
wal_level = logical              # requires a restart
max_replication_slots = 10       # >= number of subscriptions; see note below
max_wal_senders = 12             # max_replication_slots + other standbys
wal_sender_timeout = 0           # AWS recommendation: disables the idle timeout

Those values follow AWS's documented recommendations for logical replication. If your source is itself RDS or Aurora, set rds.logical_replication = 1 instead — same static-parameter-plus-reboot dance as step 2.

wal_level is static, so this needs a restart. Verify:

SELECT current_setting('wal_level');   --  logical

Sizing max_replication_slots. AWS suggests one per subscription. Be aware the true peak is higher: during initial sync PostgreSQL creates one transient slot per table being synchronised, on top of the durable slot. Leave headroom rather than sizing to exactly the number of subscriptions.

Allow the target to connect, in pg_hba.conf:

host    appdb          repl    <target-cidr>/32    scram-sha-256
host    replication    repl    <target-cidr>/32    scram-sha-256

Step 2 — Configure the target

This step catches almost everyone. The subscriber also needs logical replication enabled — not just the source. Without it you get a hard failure.

On RDS or Aurora, rds.logical_replication is a static parameter, and default parameter groups cannot be edited. So you need a custom group and a reboot:

aws rds create-db-parameter-group \
  --db-parameter-group-name pg-logical \
  --db-parameter-group-family postgres15 \
  --description "logical replication enabled"

aws rds modify-db-parameter-group \
  --db-parameter-group-name pg-logical \
  --parameters "ParameterName=rds.logical_replication,ParameterValue=1,ApplyMethod=pending-reboot"

aws rds modify-db-instance \
  --db-instance-identifier my-target \
  --db-parameter-group-name pg-logical --apply-immediately

aws rds reboot-db-instance --db-instance-identifier my-target

For Aurora, put the parameter in a DB cluster parameter group and reboot the writer.

Check ParameterApplyStatus rather than assuming — it reads pending-reboot until you actually reboot. Then confirm inside the database:

SELECT name, setting FROM pg_settings
 WHERE name IN ('wal_level', 'rds.logical_replication');
          name           | setting
-------------------------+---------
 rds.logical_replication | on
 wal_level               | logical

Step 3 — Create the replication role on the source

CREATE ROLE repl WITH LOGIN REPLICATION PASSWORD '<strong-password>';
GRANT USAGE  ON SCHEMA public TO repl;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repl;

Grant SELECT in every schema you intend to migrate. Missing column privileges cause failures partway through the initial copy.


Step 4 — Migrate global objects, then the schema

Logical replication carries neither DDL nor global objects. Tables must exist on the target before a subscription can populate them, and roles must exist before the schema dump can assign ownership and grants to them.

4a. Global objects (roles, tablespaces)

AWS states the problem plainly:

"The pg_dump utility only takes the backup of the single database at any point of time. It doesn't back up global objects such as users and groups. To migrate global objects, you need to use a combination of pg_dumpall and psql."

The documented procedure:

pg_dumpall --host <source> --globals-only --no-role-passwords \
           --username postgres > globals_only.sql

psql --host <target> --dbname appdb --username postgres -f globals_only.sql

--no-role-passwords is not optional for RDS/Aurora PG, and the reason is specific:

"Because the Amazon RDS for PostgreSQL and Aurora PostgreSQL rds_superuser role doesn't have permission on the pg_authid table, it's important to use --no-role-passwords with pg_dumpall."

Two consequences to plan for:

  • You must set role passwords separately on the target, since they were deliberately excluded.
  • Review the file before applying it. The RDS master user is a member of rds_superuser, not a true superuser, so statements granting SUPERUSER, REPLICATION, or BYPASSRLS will fail. Strip those attributes or drop those roles from the script.

Related, if your source is RDS or Aurora and pg_dump cannot see everything:

"For running the pg_dump utility, you need to have SELECT permission on all database objects. By default, rds_superuser doesn't have SELECT permission on all objects. As a workaround, grant SELECT permission to the rds_superuser role or grant permissions of other users to rds_superuser by running: GRANT <user> TO <rds_super_user>;"

4b. Schema

pg_dump --host <source> --schema-only --dbname appdb --username postgres > schema_only.sql
psql   --host <target> --username postgres --dbname appdb -f schema_only.sql

Verify the target has the tables and zero rows:

target table: audit_log
target table: customers
target table: orders
    customers=0 orders=0 audit_log=0

For a small database you can stream it and skip the intermediate file entirely, which AWS also recommends:

pg_dump --host <source> --username postgres appdb | psql --host <target> --username postgres appdb

Why not --no-owner --no-acl?

You will see these flags recommended widely on the internet. They do not appear in AWS's documented procedures, and it is worth understanding why before you reach for them.

They suppress ALTER ... OWNER TO and GRANT/REVOKE, which avoids errors when the target lacks the roles. But that is treating the symptom. I tested all three paths against the same table, owned by app_owner with SELECT granted to app_reader:

SOURCE:  owner  = app_owner
         grants = app_owner=arwdDxtm/app_owner app_reader=r/app_owner

(A) restore WITH --no-owner --no-acl
         0 errors
         owner  = postgres        ← ownership silently reassigned
         grants = (none)          ← every privilege silently dropped

(B) restore WITHOUT the flags, roles missing on target
         ERROR:  role "app_owner" does not exist
         ERROR:  role "app_reader" does not exist

(C) restore WITHOUT the flags, after step 4a
         0 errors
         owner  = app_owner       ← preserved
         grants = app_owner=arwdDxtm/app_owner app_reader=r/app_owner

Path (A) produces a clean log and a wrong database. Your application roles arrive with no privileges at all, and you discover it later as permission-denied errors from the application rather than as errors during the migration.

Do step 4a, then omit the flags. That is path (C), and it is what the AWS procedure amounts to.

Optional: faster restore for very large tables

For a big initial load, AWS recommends deferring index and constraint creation:

"Dropping foreign keys, primary keys, and indexes before restore and adding them after restore can drastically reduce migration time."

You can approximate this by splitting the dump — restore --section=pre-data before the subscription, and --section=post-data after the initial sync completes. Be aware of the interaction with the primary-key requirement below: a table needs its replica identity in place before it starts replicating changes, so if you defer primary keys you must complete the initial sync before the change stream matters.

Also useful from the same source: --jobs should be at most the number of vCPUs on the target, and pg_dump opens jobs + 1 connections, so check max_connections.


Step 5 — Create the publication

CREATE PUBLICATION appdb_pub FOR ALL TABLES;

Prefer an explicit table list if you want control over scope:

CREATE PUBLICATION appdb_pub FOR TABLE customers, orders, audit_log;

FOR ALL TABLES is simpler but has a sharp edge covered in the traps below.

The primary-key trap, live

The instant that publication exists, PostgreSQL starts refusing UPDATE and DELETE on any published table with no replica identity. Here is my source database, immediately after:

UPDATE audit_log : ERROR:  cannot update table "audit_log" because it does not have
                   a replica identity and publishes updates
                   HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.
DELETE audit_log : ERROR:  cannot delete from table "audit_log" because it does not have
                   a replica identity and publishes deletes
INSERT audit_log : (succeeded — no error)
UPDATE customers : (succeeded — it has a primary key)

Read that carefully. The error lands on your application's own writes to the source database. Not in a migration tool, not in a log you are watching. And INSERT still works, so an insert-heavy workload looks perfectly healthy while update paths break.

The fix, in preference order:

  1. Add a real primary key. Best, and the only option that carries no performance penalty.

  2. REPLICA IDENTITY FULL. AWS documents this as the supported workaround:

    "It's important to have a replication identity (primary key or unique index) for tables that are part of the publication in the logical replication. If the table doesn't have a primary key or a unique index, you can set replication identity to full (ALTER TABLE <table name> REPLICA IDENTITY FULL) where the entire row acts as a primary key."

    Understand the cost before choosing it. It writes every column of the old row to WAL, and because the subscriber has no index to match on, it must scan to find each row. AWS puts it directly: "You can replicate tables with no primary or unique keys, but updates and deletes on those tables are slow on the subscriber." Fine for a low-churn table; painful for a hot one.

  3. REPLICA IDENTITY USING INDEX with a unique, NOT NULL index. Keeps the subscriber's lookups indexed.

A non-unique index does not qualify. For my append-mostly log table, option 2 is the pragmatic choice — apply it on both sides:

-- source AND target
ALTER TABLE audit_log REPLICA IDENTITY FULL;
UPDATE audit_log now : (succeeded)
DELETE audit_log now : (succeeded)

Step 6 — Create the subscription

CREATE SUBSCRIPTION appdb_sub
  CONNECTION 'host=source-host port=5432 dbname=appdb user=repl password=<password>'
  PUBLICATION appdb_pub
  WITH (copy_data = true);

copy_data = true is what triggers the initial bulk copy. This one statement does the entire data migration.

Watch per-table progress. srsubstate = 'r' means ready and streaming:

SELECT c.relname, r.srsubstate, r.srsublsn
  FROM pg_subscription_rel r
  JOIN pg_class c ON c.oid = r.srrelid
 ORDER BY 1;
  relname   | srsubstate |  srsublsn
------------+------------+------------
 audit_log  | r          | 0/220B090
 customers  | r          | 0/220B090
 orders     | r          | 0/220B100

States progress i (initialising) → d (copying data) → s (synchronised) → r (ready). Row counts after initial sync:

TARGET: customers=5000 orders=20000 audit_log=1000
SOURCE: customers=5000 orders=20000 audit_log=1000

Note on credentials. The connection string — password included — is stored in pg_subscription.subconninfo and is readable by any superuser on the target. If you have log_statement = all enabled, it is also written to your logs in cleartext. Use a dedicated replication role, and rotate it after cutover.


Optional — a faster bulk load for large databases

Skip this section unless your initial sync is too slow. It is more moving parts in exchange for parallelism.

copy_data = true is convenient but the subscriber copies one table per worker, and there is no way to parallelise within a table. For a database with one dominant table, that single-threaded COPY sets your floor. Parallel pg_dump -j can be considerably faster.

The problem to solve is consistency: if you dump the data separately, at what point does the change stream start, so that nothing is lost or duplicated? PostgreSQL answers this by letting slot creation export a snapshot.

Note the distinction — the SQL function does not export one:

SELECT * FROM pg_create_logical_replication_slot('sqlslot','pgoutput');
--  slot_name | lsn
--  sqlslot   | 0/1C0C660          ← no snapshot

Only the replication protocol command can. Connect with replication=database to source database (RDS/APG supported as well) and keep that session open:

psql "host=source port=5432 user=postgres dbname=appdb replication=database"
CREATE_REPLICATION_SLOT bulkslot LOGICAL pgoutput (SNAPSHOT 'export');

 slot_name | consistent_point | snapshot_name       | output_plugin
 bulkslot  | 0/1C0C978        | 0000007E-00000002-1 | pgoutput

Now dump the data inside that snapshot, in parallel, from another shell:

pg_dump -h source -d appdb --data-only --snapshot='0000007E-00000002-1' -Fd -j 4 -f /dump
pg_restore -h target -d appdb -j 4 /dump

Then attach a subscription to the slot you already created, telling it not to copy:

CREATE SUBSCRIPTION mysub
  CONNECTION 'host=source port=5432 dbname=appdb user=repl password=<pw>'
  PUBLICATION mypub
  WITH (copy_data = false, create_slot = false, slot_name = 'bulkslot');

I tested this with 500 rows written after the snapshot was taken, to confirm the handoff is exact:

source rows at snapshot time : 1000
rows written after snapshot  : 500
source rows total            : 1500

target rows after snapshot dump : 1000     ← the snapshot state, not 1500
target rows after CDC catch-up  : 1500
duplicate ids                   : 0
missing ids                     : 0

The dump saw exactly the snapshot state, and the change stream supplied precisely the rows the dump did not have.

Two things will bite you here.

The snapshot lives only as long as the session that exported it. Close that replication=database connection and the snapshot is gone — pg_dump then fails with ERROR: snapshot "..." does not exist. I hit this the first time by running the steps from separate shells. The connection must stay open for the entire duration of the dump, which for a large database can be hours.

And with create_slot = false you now own that slot. If you abandon the attempt, drop it by hand — see trap 4.

For most migrations copy_data = true is the right choice. Reach for this only when you have measured the initial sync and it is genuinely the bottleneck.

Step 7 — Verify change data capture

Do not trust the plumbing; prove it. Generate real traffic on the source — inserts, updates and deletes — then compare.

SOURCE: customers=5300 orders=20900 audit_log=889
TARGET: customers=5300 orders=20900 audit_log=889

The three monitoring queries worth putting on a dashboard:

-- SOURCE: is the slot alive, and how much WAL is it pinning?
SELECT slot_name, plugin, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
  FROM pg_replication_slots;
 slot_name | plugin   | active | retained_wal
-----------+----------+--------+--------------
 appdb_sub | pgoutput | t      | 56 bytes
-- SOURCE: how far behind is the subscriber?
SELECT confirmed_flush_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS outstanding
  FROM pg_replication_slots;

retained_wal is the number that can take your source database down. If the subscriber stalls, that grows without bound until the source disk fills. Alarm on it.

confirmed_flush_lsn is what the subscriber has durably applied — not merely what was sent. It is the correct field to drain against at cutover.


Step 8 — Cut over

The ordering here is not cosmetic. Two steps are load-bearing:

  • Sequences must be read after writes stop. Read them earlier and the value you copy is already stale.
  • The subscription must be dropped after the drain, or you discard in-flight changes.

8a. Quiesce the source

Stop the application. Then make sure it cannot reconnect — stopping a service does not close connections that are already open:

ALTER DATABASE appdb CONNECTION LIMIT 0;

SELECT pg_terminate_backend(pid) FROM pg_stat_activity
 WHERE datname = 'appdb' AND pid <> pg_backend_pid() AND usename <> 'postgres';

8b. Drain

Freeze the source LSN, then wait for the subscriber to confirm it. Comparing against a frozen LSN rather than a live pg_current_wal_lsn() is what makes this terminate:

SELECT pg_current_wal_lsn();                       -- note this value, e.g. 0/22CF070
SELECT pg_wal_lsn_diff('0/22CF070', confirmed_flush_lsn) FROM pg_replication_slots;
-- repeat until 0
source WAL frozen at 0/22CF070
attempt 1: outstanding bytes=0

8c. Reconcile with exact counts

Use count(*). Do not use n_live_tup or reltuples — they are planner estimates and will disagree by thousands under load.

customers    source=5300     target=5300     OK
orders       source=20900    target=20900    OK
audit_log    source=889      target=889      OK

8d. Sync the sequences

Logical replication never replicates sequence values. This is the trap that silently corrupts an otherwise perfect migration. Before syncing, my target looked like this:

SOURCE sequences:              TARGET sequences:
  customers_id_seq = 5300        customers_id_seq = (never used)
  orders_id_seq    = 20900       orders_id_seq    = (never used)

Every row arrived, but the target's sequences never moved. Repoint your application now and it immediately issues primary keys that already exist.

Read last_value from the source and apply setval on the target:

-- on the SOURCE
SELECT schemaname || '.' || sequencename, last_value FROM pg_sequences;

-- on the TARGET, for each
SELECT setval('public.customers_id_seq', 5300 + 1000, true);
public.customers_id_seq: source=5300  -> target setval=6300
public.orders_id_seq:    source=20900 -> target setval=21900

Two details matter:

  • The margin (1000 here) covers values already handed out by nextval() into other sessions' caches but never committed. Those do not appear in last_value yet are already spoken for. Size it to your peak concurrency.
  • is_called = true makes the next nextval() return value + 1. With false, the sequence re-issues the value itself.

8e. Promote

-- on the TARGET
DROP SUBSCRIPTION appdb_sub;

Then verify on the source that the slot is gone:

SELECT count(*) FROM pg_replication_slots;   -- must be 0
NOTICE:  dropped replication slot "appdb_sub" on publisher
source slots remaining = 0

DROP SUBSCRIPTION drops the publisher's slot too, provided the source is reachable. If it is not, the drop hangs — detach it first:

ALTER SUBSCRIPTION appdb_sub SET (slot_name = NONE);
DROP SUBSCRIPTION appdb_sub;
-- then on the source:
SELECT pg_drop_replication_slot('appdb_sub');

Finally drop the publication:

DROP PUBLICATION appdb_pub;

8f. Verify the new primary

subscriptions=0
in_recovery=false
target issued new customer id = 6301   (source high-water mark was 5300)
PASS: no primary-key collision possible

That last check is the one that proves the sequence sync worked. Insert a row on the target and confirm the generated key is strictly above the source's high-water mark.


The four traps, summarised

1. Tables without a primary key. Once a publication exists, source UPDATE/DELETE are rejected. The failure surfaces in your application, not in any migration tool, and INSERT keeps working so partial workloads mask it. Audit first (step 0).

2. Sequences are not replicated. Every sequence on the target sits at its dump-time value. Sync with setval after writes stop and before repointing the application.

3. DDL is not replicated. A column or table added on the source never appears on the target, with no error anywhere. AWS is explicit about the remedy:

"Any DDL changes aren't replicated. To avoid any interruption in replication, DDL changes such as any changes on table definition should be done at both sides at the same time."

There is a specific version of this worth calling out. With FOR ALL TABLES, a newly created source table is added to the publication automatically — but the subscriber ignores it until you run:

ALTER SUBSCRIPTION appdb_sub REFRESH PUBLICATION;

In testing I saw a new table with 500 rows on the source and 0 on the target, with no error on either side.

3b. Some object types cannot be replicated at all. Per AWS: "Views, materialized views, partition root tables, or foreign tables can't be replicated." These come across in the schema dump (step 4b) as definitions, but no rows will flow into a materialized view and you cannot subscribe a partition root. Plan to refresh materialized views on the target after cutover, and replicate partitioned tables by targeting the leaf partitions.

Large objects (pg_largeobject) are also invisible to logical replication. If you use lo_* functions, migrate that data separately with pg_dump or move off large objects first.

4. An abandoned replication slot will fill your source disk. This is the most damaging way to get this wrong, because it takes down the database you were migrating away from. An inactive logical slot retains WAL indefinitely. Always confirm zero slots after cutover, and alarm on retained_wal during the migration.

Also plan rollback before you cut over. Once the slot is dropped, changes on the target are tracked nowhere. Your options are reverse replication configured in advance — which requires primary keys on every table — or a target snapshot as a fixed recovery point.


When a managed service is the better call

Self-managed replication is not always the right answer. AWS Database Migration Service is the better choice when any of these apply.

You need built-in data validation. This is the biggest functional gap in the approach described here. Nothing in PostgreSQL logical replication verifies that the target matches the source — you get sync state, not content verification. DMS replication tasks include row-level validation that runs continuously and reports mismatches. If equivalence has to be proven for audit or regulatory sign-off, either budget for building that yourself or use a service that does it.

Note that not every DMS migration type includes validation. AWS documents that homogeneous data migrations, specifically, do not:

"Homogeneous data migrations don't provide a built-in tool for data validation."

So check which DMS migration type you are choosing against your validation requirement.

You need to reshape data in flight. Renaming, retyping, filtering rows or columns during migration. Logical replication is faithful by design — it gives you the same rows, not different ones.

Your source is not PostgreSQL. Logical replication is PostgreSQL-to-PostgreSQL only.

You would rather not own the operational surface. Slot monitoring, WAL retention alarms, cutover orchestration and reconciliation are all yours in the self-managed approach. That is real work, and a managed service absorbing it is a legitimate reason to choose one.

One thing that is not a differentiator: downtime. Any change-data-capture-based migration is bounded by the same cutover window, because the bulk load and the change stream both run with the source available. Do not choose between these options on downtime; choose on validation needs, transformation needs, and how much operational surface you want to own.

A note if you go the DMS route with a PostgreSQL source

DMS replication tasks do not use pgoutput. Per the AWS documentation, DMS creates its replication slot with pglogical if that extension is present on your source, and falls back to test_decoding otherwise. You can pin it with the PluginName endpoint setting.

This matters for the primary-key problem discussed above, because the remedies are not the same. AWS documents the constraint directly:

"REPLICA IDENTITY FULL is supported with a logical decoding plugin, but isn't supported with a pglogical plugin."

In other words, if your pipeline ends up on pglogical, REPLICA IDENTITY FULL will not rescue a table without a primary key — only a real primary key will. Worth knowing before you pick a remedy for that audit table.


Reference: the whole thing

-- SOURCE: wal_level = logical, restart, then
CREATE ROLE repl WITH LOGIN REPLICATION PASSWORD '<pw>';
GRANT USAGE ON SCHEMA public TO repl;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO repl;
ALTER TABLE <pk_less_table> REPLICA IDENTITY FULL;      -- per step 0 audit
CREATE PUBLICATION appdb_pub FOR ALL TABLES;
# SCHEMA first
pg_dump -h source -d appdb --schema-only | psql -h target -d appdb
-- TARGET: rds.logical_replication = 1, reboot, then
ALTER TABLE <pk_less_table> REPLICA IDENTITY FULL;
CREATE SUBSCRIPTION appdb_sub
  CONNECTION 'host=source port=5432 dbname=appdb user=repl password=<pw>'
  PUBLICATION appdb_pub WITH (copy_data = true);

-- wait for every row: srsubstate = 'r'
SELECT c.relname, r.srsubstate FROM pg_subscription_rel r
  JOIN pg_class c ON c.oid = r.srrelid;
-- CUTOVER
-- 1. stop the application; ALTER DATABASE appdb CONNECTION LIMIT 0;
-- 2. drain: wait until pg_wal_lsn_diff(<frozen lsn>, confirmed_flush_lsn) = 0
-- 3. reconcile: count(*) per table, both sides
-- 4. sequences: setval(seq, source_last_value + margin, true) on the target
-- 5. DROP SUBSCRIPTION appdb_sub;  then verify source slots = 0
-- 6. repoint the application

Every command in this post was executed, not sketched. Tested on PostgreSQL 18.6 — two clusters, 26,000 rows across three tables, live write traffic during the migration, and a full cutover including sequence synchronisation. The replica-identity behaviour was additionally verified on PostgreSQL 15.19, so it is not version-specific. The RDS parameter-group procedure in step 2 was validated against a live Amazon RDS for PostgreSQL 15.19 instance.