Photograph of a mahogany library desk with handwritten manuscript, brass lamp with green shade, taxidermied duck, leather-bound books, and brass magnifying glasses, against floor-to-ceiling bookshelves and stained-glass window.

logical_decoding_work_mem is how much of somebody else’s uncommitted transaction a walsender will hold in memory before it starts writing that transaction to disk a second time.

The name suggests a cousin of work_mem; the mechanism is closer to a swap file. Logical decoding reads WAL and reassembles the records into per-transaction change lists, in a structure called the reorder buffer. Unless the consumer has asked for streaming (more on that below), the decoder can hand a transaction to the output plugin only after it sees the commit record, and only in commit order. So every change a still-open transaction has made so far is sitting in the walsender’s memory, waiting to find out whether it commits. A 20 GB batch load is 20 GB of somebody else’s data in a process that didn’t ask for it.

Before PostgreSQL 13 the only limit was a hard-coded 4,096 changes per transaction, after which that transaction’s changes went to disk. There was no byte limit at all, which is why walsenders used to get killed by the OOM killer, and why this parameter exists. Since 13, the reorder buffer keeps a byte total across every in-progress transaction it is tracking; when the total crosses logical_decoding_work_mem, it picks the largest transaction and serializes it to pg_replslot/<slot>/.

Default 64MB, minimum 64kB, context user. The context matters more than usual here. A reload changes the limit for a walsender that is already running (I tested this; the running walsender picked up the new value without reconnecting). ALTER ROLE replicator SET logical_decoding_work_mem = '1GB' applies to the replication role the next time it connects, which is the cleanest way to raise it for replication without raising it for everything. And a session calling pg_logical_slot_get_changes() decodes under its own setting, so SET it before you call the function.

What “spill” actually costs

Spilling is not “the excess goes to disk.” Once a transaction crosses the line, the whole transaction ends up on disk, including the part that was under the limit: in my tests a 300,000-row insert produced 342,000,636 bytes of decoded changes and 342,000,000 bytes of spill. The files appear in pg_replslot/<slot>/ as xid-<xid>-lsn-<segment>.spill, one per 16 MB WAL segment the transaction touched, and they appear while the transaction is still open; the walsender has no idea yet whether it will commit. The spill volume also tracked the WAL volume of the transaction (109 MB of WAL became 109 MB of spill; 396 MB became 395 MB), so a large transaction on a logically-replicated database costs the publisher’s disk roughly twice: once for WAL, once for the spill, and then the spill is read back at commit and shipped. The walsender shows ReorderBufferWrite and ReorderBufferRead as its wait events while this is going on, which is how you’ll spot it in pg_stat_activity.

The budget is measured in decoded changes, not row bytes. A row with a 1-character payload costs about 138 bytes of it (the change header plus the tuple), so 64 MB is roughly 480,000 narrow rows, or about 59,000 rows of a kilobyte each. The process footprint also runs past the number, because the accounting doesn’t include allocator overhead: with 1 KB rows the walsender peaked about 65 MB above its idle size, but with narrow rows it peaked about 115 MB above. Plan on the limit times two, times the number of logical slots that could be decoding a big transaction at the same moment.

PostgreSQL 17 fixed a related pathology. Picking “the largest transaction” used to mean scanning every transaction and subtransaction the reorder buffer knew about, every time the buffer filled. PL/pgSQL EXCEPTION blocks are subtransactions, so a loop with an exception handler inside it can hand the decoder 100,000 of them, and the reporter of the bug measured over two minutes to decode a single top-level transaction. On 16 and earlier, if a stored procedure makes logical decoding fall over, that’s what you’re looking at.

Streaming moves the problem; parallel apply removes it

Since PostgreSQL 14 an output plugin can ask for in-progress transactions to be streamed: when the buffer fills, the largest transaction is shipped to the consumer in a block instead of being written locally, and the consumer sorts out commit-or-abort later. For built-in logical replication that is the subscription’s streaming option, which is set on the subscriber while logical_decoding_work_mem is set on the publisher; the two halves of this decision live on different servers. streaming = on gets the spill off the publisher, but it only moves it: the subscriber’s apply worker writes the streamed changes into its own temporary files under pgsql_tmp (I watched 289 MB pile up there) and applies them at commit. streaming = parallel, added in 16, hands the in-progress transaction to a parallel apply worker that applies the changes as they arrive, and nothing hits disk on either side beyond the actual apply. PostgreSQL 18 made parallel the default for new subscriptions; subscriptions created on 14 through 17 keep whatever they were created with, and the pre-18 default was off.

With streaming enabled, this parameter stops being a spill threshold and becomes a batch size: how much to accumulate before shipping a block. It still matters, but the stakes drop. Third-party consumers speak their own protocol versions and many never request streaming, in which case spill is the only mechanism you have.

One more 18 change: if a transaction has already rolled back by the time the decoder would evict it (typically because the consumer was behind), 18 checks the transaction status first and discards the changes instead of spilling them. On 17 and earlier, an aborted 20 GB transaction gets written to pg_replslot like any other and thrown away afterward.

The place to look is pg_stat_replication_slots (14 and later): spill_txns, spill_count, spill_bytes, their stream_* counterparts, and total_txns and total_bytes. The ratio to watch is spill_bytes to total_bytes; if it’s near 1, nearly everything you decode is going through the disk. spill_count is the number of evictions, so with the default it’s roughly the number of 64 MB blocks written. Reset with pg_stat_reset_replication_slot('slot') and look again after a representative day. On 13 you have ls pg_replslot/*/ and not much else.

If you have logical replication slots at all, raise this. 256MB is a floor; 1GB is not extravagant on a machine with memory to spare, and the documentation’s remark that “it’s safe to set this value significantly higher than work_mem” is one of the rare places the docs editorialize and are right. Budget it per active logical slot, with the doubling above. What raising it will not do is make a 30 GB batch job fit. Nothing will. That is a streaming = parallel problem, and if the consumer can’t stream, it’s a “stop doing 30 GB transactions” problem, which is a conversation with an application team rather than a parameter.