Decode PostgreSQL’s native pgoutput binary replication stream in Python by driving psycopg2’s LogicalReplicationConnection and parsing each message byte-for-byte — the concrete decoding step inside Python CDC parser development. Getting the byte alignment wrong does not throw a clean error; it silently mis-attributes columns or desyncs the stream, so a restart_lsn that never advances quietly fills pg_wal until the primary refuses writes.
This page assumes the logical decoding subsystem is already enabled and a logical replication slot exists on the pgoutput plugin. It covers the exact wire semantics of each message tag, diagnostic queries to isolate a decoder bug from a slot problem, a zero-downtime deployment sequence, and the feedback loop that keeps WAL bounded. Everything targets PostgreSQL 14 through 17.
Message Type Semantics
pgoutput emits a strict binary protocol over the WAL stream mechanics that decode committed transactions. psycopg2 hands each replication message to your callback as a complete payload bytes object — there is no separate length prefix to consume. Every message begins with a single-byte type tag; the payload layout that follows is fixed per tag. All integers are network byte order (big-endian); strings are null-terminated UTF-8.
| Tag | Message | Key payload fields | Decoder action | Consumer behavior |
|---|---|---|---|---|
B |
BEGIN | final_lsn (Q), timestamp (q), xid (I) |
Open a transaction buffer keyed by xid |
Delimits an atomic batch; no rows yet |
R |
RELATION | rel_id (I), namespace, name, replica identity, natts (H), per-column metadata |
Cache column list under rel_id |
Schema context for every later tuple; re-emitted after DDL |
I |
INSERT | rel_id (I), N tuple |
Decode new-tuple against cached relation | Append full new row to the batch |
U |
UPDATE | rel_id (I), optional K/O old tuple, N new tuple |
Decode old (if present) + new tuple | Old image present only under REPLICA IDENTITY FULL/USING INDEX |
D |
DELETE | rel_id (I), K/O old tuple |
Decode old tuple (key or full row) | Carries only the replica identity columns |
C |
COMMIT | flags (B), commit_lsn (Q), end_lsn (Q), timestamp (q) |
Flush batch to sink, then acknowledge end_lsn |
The only point at which it is safe to advance the slot |
T |
TRUNCATE | nrelids (I), flags, relation ids |
Emit a truncate event per relation | Cascades and RESTART IDENTITY encoded in flags |
O/Y/M |
ORIGIN / TYPE / MESSAGE | origin name, type OIDs, logical message | Optional; skip if unused | Origin filtering, custom types, pg_logical_emit_message |
Inside every tuple, each column value is prefixed by a one-byte kind flag that decides how many bytes to read next. This is the highest-risk part of the parser:
| Flag | Meaning | Bytes that follow |
|---|---|---|
n |
SQL NULL |
none |
u |
Unchanged TOASTed value (not sent) | none — reuse the prior value or mark absent |
t |
Textual value | 4-byte length I, then that many UTF-8 bytes |
b |
Binary value (proto v2+, binary option) |
4-byte length I, then raw bytes |
The u flag is the classic silent-corruption trap: it is not NULL. Writing NULL to the sink on a u flag overwrites an unchanged large value with nothing. Under REPLICA IDENTITY FULL — the safe default for a hand-built parser, see publication and subscription models — UPDATE and DELETE carry a full old-tuple image so downstream upserts and tombstones reconstruct exactly.
A production decoder is stateful: it caches RELATION messages so rel_id resolves to a fully qualified table name and column set without redundant catalog lookups. The core parser below reads a message into an io.BytesIO cursor and dispatches on the tag.
import struct
import io
from typing import Dict, Any
class PgOutputParser:
"""Binary pgoutput decoder aligned with the PostgreSQL 14+ protocol."""
def __init__(self) -> None:
self.relations: Dict[int, Dict[str, Any]] = {} # rel_id -> schema cache
@staticmethod
def _read_string(buf: io.BytesIO) -> str:
out = bytearray()
while (byte := buf.read(1)) and byte != b"\x00":
out += byte
return out.decode("utf-8", errors="replace")
def _read_tuple(self, buf: io.BytesIO, columns) -> Dict[str, Any]:
natts = struct.unpack("!H", buf.read(2))[0]
row: Dict[str, Any] = {}
for i in range(natts):
name = columns[i]["name"] if i < len(columns) else f"col_{i}"
flag = buf.read(1)
if flag in (b"n", b"u"):
# 'n' = SQL NULL; 'u' = unchanged TOAST — DISTINCT meanings.
row[name] = None if flag == b"n" else _UNCHANGED
elif flag in (b"t", b"b"):
length = struct.unpack("!I", buf.read(4))[0]
value = buf.read(length)
row[name] = value.decode("utf-8", errors="replace") if flag == b"t" else value
return row
def parse_message(self, payload: bytes) -> Dict[str, Any]:
buf = io.BytesIO(payload)
tag = buf.read(1)
if not tag:
return {}
if tag == b"B": # BEGIN
final_lsn, ts, xid = struct.unpack("!QqI", buf.read(20))
return {"type": "BEGIN", "final_lsn": final_lsn, "xid": xid}
if tag == b"C": # COMMIT
flags = buf.read(1)
commit_lsn, end_lsn, ts = struct.unpack("!QQq", buf.read(24))
return {"type": "COMMIT", "commit_lsn": commit_lsn, "end_lsn": end_lsn}
if tag == b"R": # RELATION (schema)
rel_id = struct.unpack("!I", buf.read(4))[0]
schema = self._read_string(buf)
table = self._read_string(buf)
buf.read(1) # replica identity setting
ncols = struct.unpack("!H", buf.read(2))[0]
cols = []
for _ in range(ncols):
buf.read(1) # column flags (1 = part of key)
cname = self._read_string(buf)
type_oid, atttypmod = struct.unpack("!Ii", buf.read(8))
cols.append({"name": cname, "type_oid": type_oid})
self.relations[rel_id] = {"schema": schema, "table": table, "columns": cols}
return {"type": "RELATION", "rel_id": rel_id, "schema": schema, "table": table}
if tag in (b"I", b"U", b"D"): # INSERT / UPDATE / DELETE
rel_id = struct.unpack("!I", buf.read(4))[0]
rel = self.relations.get(rel_id)
if rel is None:
raise KeyError(f"rel_id {rel_id} seen before its RELATION message")
cols = rel["columns"]
old = new = None
if tag == b"U":
sub = buf.read(1) # 'K' key, 'O' full old tuple, or start of 'N'
if sub in (b"K", b"O"):
old = self._read_tuple(buf, cols)
buf.read(1) # 'N'
new = self._read_tuple(buf, cols)
elif tag == b"I":
buf.read(1) # 'N'
new = self._read_tuple(buf, cols)
else: # DELETE
buf.read(1) # 'K' or 'O'
old = self._read_tuple(buf, cols)
return {"type": tag.decode(), "table": f'{rel["schema"]}.{rel["table"]}',
"old": old, "new": new}
return {"type": "unknown", "tag": tag.decode(errors="replace")}
_UNCHANGED = object() # sentinel: leave the sink's existing value untouched
Diagnostic Patterns
Decoder defects and slot defects look identical from the client — the stream just stops. Separate them from the server side before touching Python.
Peek at exactly what the slot will emit, without consuming it. pg_logical_slot_peek_binary_changes reads WAL without advancing the cursor, so you can inspect the raw bytes your parser is about to see:
-- Inspect the next 20 messages WITHOUT acknowledging anything.
SELECT lsn, xid, left(encode(data, 'hex'), 80) AS first_bytes
FROM pg_logical_slot_peek_binary_changes(
'cdc_pgoutput_slot', NULL, 20,
'proto_version', '1', 'publication_names', 'cdc_py');
If peek returns well-formed messages (a B/0x42 first byte on the first row of a transaction) but your consumer chokes, the bug is in the Python decoder, not the slot.
Watch retained WAL and liveness. active = false on a slot that should be streaming means the parser died or never reconnected:
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_name = 'cdc_pgoutput_slot';
-- Alert when retained_wal exceeds ~25% of max_slot_wal_keep_size, or when
-- active = false persists beyond 60 s on a slot expected to be live.
Confirm the sender is shipping bytes. A non-zero, steadily-growing unflushed value means the walsender is ahead of your acknowledged position — normal briefly, a stall if it never shrinks:
SELECT application_name, state,
pg_size_pretty(pg_wal_lsn_diff(sent_lsn, flush_lsn)) AS unflushed,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS total_lag
FROM pg_stat_replication;
These map onto the same panels described in async monitoring integration.
0x42 first byte from peek puts the bug in the decoder, while empty or malformed bytes plus a stalled slot point at a WAL/slot problem — each branch has its own remediation.Safe Deployment Sequence
Roll a pgoutput consumer out without disturbing the primary, and keep a clean revert path.
1. Enforce the server parameter baseline. Deviations cause silent WAL truncation or protocol rejection. wal_level requires a restart, so plan the bounce:
ALTER SYSTEM SET wal_level = 'logical'; -- restart required
ALTER SYSTEM SET max_replication_slots = '10'; -- restart required
ALTER SYSTEM SET max_wal_senders = '10'; -- restart required
ALTER SYSTEM SET wal_sender_timeout = '60s'; -- reload-safe
ALTER SYSTEM SET max_slot_wal_keep_size = '10GB'; -- PG 13+; finite = self-protecting
SELECT pg_reload_conf();
Verify with SHOW wal_level; and confirm the slot object exists. Set max_slot_wal_keep_size to a finite value so a stalled parser invalidates its own slot rather than filling the disk — a resnapshot is almost always the right trade over a downed primary.
2. Create the slot and narrow the stream. The publication decides which tables and operations reach the wire; the slot guarantees WAL retention until you acknowledge an LSN. Full provisioning detail is under initializing replication slots:
CREATE PUBLICATION cdc_py FOR TABLE public.orders, public.line_items;
ALTER TABLE public.orders REPLICA IDENTITY FULL; -- full old images
ALTER TABLE public.line_items REPLICA IDENTITY FULL;
SELECT * FROM pg_create_logical_replication_slot('cdc_pgoutput_slot', 'pgoutput');
3. Open the replication connection in binary mode. The replication=database DSN parameter is mandatory; omitting it defaults to standard query mode and raises psycopg2.ProgrammingError. decode=False is required so pgoutput bytes reach your parser intact:
import psycopg2
from psycopg2.extras import LogicalReplicationConnection
DSN = ("host=primary-db port=5432 dbname=cdc_source user=cdc_reader "
"password=use-a-secret-manager replication=database connect_timeout=10")
def init_consumer(slot_name: str, publication: str):
conn = psycopg2.connect(DSN, connection_factory=LogicalReplicationConnection)
cur = conn.cursor()
cur.start_replication(
slot_name=slot_name,
decode=False, # binary pgoutput
options={"proto_version": "1", "publication_names": publication},
status_interval=10, # keepalive cadence, seconds
)
return conn, cur
Always confirm the slot is inactive before start_replication — a second consumer on an active slot desyncs the protocol. On PG 14+, set proto_version to 2 to stream in-progress transactions and bound memory on very large commits.
4. Validate with a dry read. Consume a handful of messages and log the decoded tags before wiring the real sink; confirm you see BEGIN → RELATION → INSERT/... → COMMIT in order and that column names resolve from the schema cache.
5. Revert cleanly. To back out, stop the consumer, then drop the slot so no WAL is retained — this is the single most important teardown step:
SELECT pg_drop_replication_slot('cdc_pgoutput_slot'); -- releases retained WAL
DROP PUBLICATION IF EXISTS cdc_py;
Never leave an inactive slot behind on rollback; it pins restart_lsn indefinitely.
Pipeline Integration
The decoder is one stage; correctness depends on when you acknowledge. PostgreSQL retains WAL until the consumer confirms an LSN via a standby status update. send_feedback is called on the cursor, not the connection, and only after the sink has durably persisted the batch.
import select
def consume_stream(cur, parser: PgOutputParser, sink) -> None:
batch, begin_lsn = [], None
while True:
msg = cur.read_message()
if msg is None:
# Idle: block on the socket but wake to send a keepalive so the
# walsender does not drop us at wal_sender_timeout.
if not select.select([cur], [], [], 10)[0]:
cur.send_feedback() # keepalive only; no LSN advance
continue
event = parser.parse_message(msg.payload)
etype = event.get("type")
if etype == "BEGIN":
batch, begin_lsn = [], msg.data_start
elif etype in ("I", "U", "D"):
batch.append(event)
elif etype == "COMMIT":
sink.write_batch(batch, event["commit_lsn"]) # raises on failure
# ONLY now is it safe to let PostgreSQL reclaim WAL up to here.
cur.send_feedback(flush_lsn=msg.data_start, write_lsn=msg.data_start,
apply_lsn=msg.data_start, reply=True)
batch = []
Advancing flush_lsn before write_batch confirms durability is how you lose data on a crash: PostgreSQL discards WAL you never stored. Persist the sink offset and the LSN in one atomic write so recovery resumes from a single source of truth.
Backpressure. Keep the feedback cadence at or below wal_sender_timeout / 3. When a downstream sink lags, buffer into a bounded queue and pause read_message before the queue saturates — never stall the acknowledgment path to wait on downstream I/O.
Typed contracts and routing. pgoutput yields raw column text; convert it into a registry-governed record via JSON to Avro transformation, then partition and dead-letter events through event routing and Kafka integration. Embed xid and commit_lsn in the payload so the sink can deduplicate on replay.
Failover. Logical slots are not replicated to standbys by default; PG 17 adds opt-in slot synchronization via the failover flag and pg_sync_replication_slots(). Without it, treat a lost slot as a resnapshot: recreate the slot, reconcile against your last durably persisted LSN — mirroring subscription sync procedures — and only then resume. Watch pg_replication_slots.wal_status for lost or extended, which flag WAL gaps needing intervention.
Authoritative references
- PostgreSQL: Logical Streaming Replication Protocol — pgoutput message formats
- PostgreSQL: Logical Decoding and the logical replication protocol
- PostgreSQL:
pg_logical_slot_peek_binary_changesandmax_slot_wal_keep_size - psycopg2: replication support
Related
- Building a Python logical decoding plugin — the C/FFI alternative that decodes WAL inside the backend instead of over the wire.
- Python CDC parser development — the parent guide to the state machine, slot lifecycle, and failure modes this decoder plugs into.
- JSON to Avro transformation — turning the raw change envelope into a registry-validated, typed contract.