The idea: turn the WAL back into row changes
Every change in PostgreSQL is written to the write-ahead log (WAL) first. Physical replication ships those bytes as they are. Logical decoding reads the same WAL and turns it back into row changes: "INSERT into orders (id 3, 'new')", grouped per transaction. A walsender process does the reading through a replication slot, an output plugin formats the result, and a consumer receives it: another PostgreSQL (logical replication) or a change-data-capture tool such as Debezium.
The page runs one publisher and one subscriber. Sessions T1, T2 and T3 write rows; Decode & Send runs the walsender; Checkpoint removes old WAL; Stop Consumer disables the subscription.
wal_level = logical
With the default wal_level = replica the WAL describes changes to pages, which is enough to replay them on an identical copy, but not to say which row of which table changed. wal_level = logical adds what decoding needs: the key of an updated or deleted row (its replica identity, by default the primary key) and catalog information so the decoder can map a file to a table and its columns. It costs a little more WAL.
The reorder buffer and commit order
Concurrent transactions write their records into the WAL interleaved, in the order the changes happen. A consumer wants whole transactions, and only committed ones. The decoder keeps a reorder buffer in the walsender's memory with one entry per transaction id (xid). Each change goes into its xid's entry. At the xid's COMMIT record the whole entry is handed to the output plugin; at an ABORT it is thrown away. So the consumer sees transactions one after another, in commit order, never half a transaction and never a rolled-back one (Demo: interleaved transactions arrive in commit order, Demo: a rolled-back transaction is never sent).
Big transactions spill from memory to disk once they pass logical_decoding_work_mem (64 MB by default). Since PostgreSQL 14 a subscription created with streaming = on can receive a large transaction while it is still running and apply it at commit.
Output plugins
The output plugin decides the format. pgoutput is built in and is what publications and subscriptions use: a binary protocol of messages such as B (begin), R (relation: table and columns), I/U/D (insert, update, delete) and C (commit). Debezium also reads pgoutput. test_decoding is a contrib example that prints text, handy for learning:
SELECT * FROM pg_create_logical_replication_slot('demo', 'test_decoding');
INSERT INTO orders VALUES (3, 'new');
SELECT data FROM pg_logical_slot_get_changes('demo', NULL, NULL);
BEGIN 731
table public.orders: INSERT: id[integer]:3 status[text]:'new'
COMMIT 731
Other plugins, such as wal2json, produce JSON.
Publications and subscriptions
Logical replication is logical decoding plus a built-in consumer. On the publisher, CREATE PUBLICATION pub_orders FOR TABLE orders says which tables (and which of insert, update, delete, truncate) to send. On the subscriber, CREATE SUBSCRIPTION sub_orders CONNECTION '…' PUBLICATION pub_orders creates a slot on the publisher, copies the existing rows once, then starts an apply worker that receives the stream and applies each transaction locally. Unlike physical replication, the subscriber is a normal writable database: it may have other tables, other indexes, even another major version.
The slot: confirmed_flush_lsn and restart_lsn
A replication slot is a small record on the publisher that remembers how far its consumer has got, across disconnects and restarts. It keeps two positions:
| Field | Meaning | Moves when |
|---|---|---|
confirmed_flush_lsn | The consumer has safely stored everything up to here. Streaming resumes after it. | The consumer sends feedback (a standby status update with its flush LSN). |
restart_lsn | The oldest WAL the decoder may still need: the first record of the oldest transaction that was still open at the confirmed point. | That transaction commits or aborts and is confirmed. |
The two differ because a transaction may have started long before the confirmed point. After a restart the walsender has lost its reorder buffer, so it reads again from restart_lsn and rebuilds it; commits at or before confirmed_flush_lsn are skipped, not sent twice. A checkpoint may only remove WAL segments that end before every slot's restart_lsn (Demo: feedback moves the slot, checkpoint frees WAL). That is also why one long-running open transaction holds WAL back.
When the consumer stops, WAL piles up
A slot does not care whether its consumer is alive. If the subscriber is down, the CDC connector is stopped, or a slot was created and forgotten, restart_lsn stays still and every checkpoint has to keep all WAL after it. pg_wal grows until the disk is full, and then the publisher itself stops: PANIC: could not write … No space left on device (Demo: consumer down, WAL piles up). When the consumer comes back it reads all that WAL, catches up, and the next checkpoint frees it.
max_slot_wal_keep_size (PostgreSQL 13+, default -1 = no limit) caps how much WAL a slot may hold. Past the cap wal_status in pg_replication_slots turns unreserved; the next checkpoint removes the WAL anyway and the slot becomes lost. The consumer then fails with "can no longer get changes from replication slot" and must be re-synced from a fresh copy (Demo: max_slot_wal_keep_size invalidates the slot). It is a choice: a full disk on the primary, or a broken subscriber.
What to watch:
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
Logical vs physical replication
| Physical (streaming) | Logical | |
|---|---|---|
| Sends | WAL bytes, page changes | Decoded row changes per committed transaction |
| Scope | The whole cluster | Chosen tables (publication) |
| Replica | Read-only, same major version | Writable, may differ in version and schema |
| Not sent | (everything is) | DDL (schema changes), sequence values, large objects |
See Primary–Replica Replication for physical WAL shipping and lag, Change Data Capture for a Debezium connector that reads this stream into Kafka, PostgreSQL WAL internals and PostgreSQL checkpoints for how WAL is written and recycled, and How PostgreSQL Runs a Query for the bigger picture.
The MySQL counterpart, binlog streaming to replicas with GTIDs, is in MySQL Replication Internals.
What the page leaves out
Real LSNs are byte positions such as 0/16B3748, and segments hold thousands of records; the page uses four per segment. It shows only INSERTs, not UPDATE/DELETE with old keys, TRUNCATE, the relation (R) messages, or the initial table copy. Feedback is sent periodically, not once per pass, and checkpoints also keep WAL for wal_keep_size, max_wal_size and physical standbys. Spilling large transactions to disk, streaming in-progress transactions, two-phase commit decoding, the historic catalog snapshot the decoder uses, failover slots and conflict handling on the subscriber are all skipped.