REPLICA IDENTITY decides exactly which old-row columns PostgreSQL writes into the WAL for every UPDATE and DELETE, and therefore what key a downstream consumer receives to locate the row it must change — a choice that sits underneath every publication/subscription model and silently governs both correctness and WAL volume. Pick NOTHING on a table that publishes deletes and the DELETE fails outright; pick FULL on a wide, high-churn table and you multiply WAL generation for change-data-capture (CDC) with no correctness benefit.
This page fixes what the four identity settings — DEFAULT, USING INDEX, FULL, and NOTHING — actually emit as the old-tuple key, the index each one requires, the unchanged-TOAST pitfall that nulls columns downstream, and the WAL trade-off for a CDC stream. It assumes you have already modelled your publications and are deciding how each published table identifies a changed row. All behavior is validated against PostgreSQL 14 through 17.
UPDATE/DELETE event: from a single primary-key column under DEFAULT to the whole prior row under FULL, or nothing at all under NOTHING.Replica Identity Semantics
REPLICA IDENTITY changes nothing about INSERT — a new row is always shipped in full — but it dictates the old-tuple key on UPDATE and DELETE, which is how the subscriber or CDC consumer finds the row to modify. Each setting trades index requirements and WAL volume against the ability to replicate those operations at all. The table below fixes the exact behavior, targeting PostgreSQL 14–17.
REPLICA IDENTITY |
Old-tuple key emitted | Requirement | WAL / CDC impact |
|---|---|---|---|
DEFAULT |
The primary-key columns only | A PRIMARY KEY must exist |
Minimal extra WAL. Correct default for CDC; UPDATE/DELETE fail if the table has no PK |
USING INDEX idx |
The columns of the named index | Index must be UNIQUE, not partial, not deferrable, on NOT NULL columns |
Minimal extra WAL. Use when the natural key is a unique index other than the PK |
FULL |
Every column of the prior row | No index required | High extra WAL on wide/updated rows; downstream matches on the full old row (PG 16+ can use a subscriber index) |
NOTHING |
Nothing | — | Zero WAL, but any UPDATE/DELETE on a table that publishes them raises cannot update/delete ... because it does not have a replica identity |
Two consequences follow directly. First, a table with no primary key and REPLICA IDENTITY DEFAULT behaves like NOTHING for updates and deletes — the identity is default, but there is no key to log, so those operations are rejected the moment the table is published for them. Second, FULL is a correctness fallback, not a performance choice: it makes any table replicable without a key, but it logs the entire prior row into WAL on every change and forces the subscriber to scan for a full-row match, which is why it belongs only on small, low-churn tables. The way these old-tuple keys travel inside the pgoutput frames is detailed in the CDC pipeline guide.
Diagnostic Patterns
Audit every published table’s identity before you trust the stream — a missing or wrong identity is invisible until the first UPDATE or DELETE fails. The relreplident column in pg_class encodes the setting as a single character:
-- Replica identity of every published table. 'd'=default 'i'=index 'f'=full 'n'=nothing.
SELECT n.nspname AS schema, c.relname AS table,
CASE c.relreplident
WHEN 'd' THEN 'default (PK)' WHEN 'i' THEN 'using index'
WHEN 'f' THEN 'full' WHEN 'n' THEN 'nothing'
END AS replica_identity
FROM pg_publication_tables pt
JOIN pg_class c ON c.relname = pt.tablename
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
ORDER BY replica_identity, schema, table;
The dangerous combination is relreplident = 'd' on a table with no primary key. Find those before they break replication:
-- Published tables using DEFAULT identity but lacking a primary key => UPDATE/DELETE will fail.
SELECT pt.schemaname, pt.tablename
FROM pg_publication_tables pt
JOIN pg_class c ON c.relname = pt.tablename
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
WHERE c.relreplident = 'd'
AND NOT EXISTS (
SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary
);
When a table uses USING INDEX, confirm the referenced index still satisfies the constraints — dropping or altering it silently invalidates the identity:
-- The index backing a USING INDEX identity must remain UNIQUE, non-partial, NOT NULL.
SELECT c.relname AS table, ix.relname AS identity_index,
i.indisunique, i.indpred IS NOT NULL AS is_partial
FROM pg_class c
JOIN pg_index i ON i.indrelid = c.oid AND i.indisreplident
JOIN pg_class ix ON ix.oid = i.indexrelid
WHERE c.relreplident = 'i';
Any row where indisunique is false or is_partial is true means the identity index no longer qualifies and updates will error until it is fixed.
Safe Deployment Sequence
Changing REPLICA IDENTITY is a fast catalog-only ALTER TABLE that takes an exclusive lock for an instant, but it changes what downstream consumers receive, so sequence it so no in-flight change is misinterpreted.
-
Pick the identity from the key you actually have. If the table has a stable primary key,
DEFAULTis correct and needs no change. Only reach forUSING INDEXwhen the CDC join key is a different unique column, and reserveFULLfor keyless tables you cannot add a key to. -
For
USING INDEX, create the qualifying index first. It must be unique, non-partial, non-deferrable, and onNOT NULLcolumns:sql CREATE UNIQUE INDEX CONCURRENTLY customers_email_uk ON customers (email); -- email must be NOT NULL for the index to be eligible as a replica identity. -
Set the identity. The change is instantaneous and affects only WAL written after it:
sql ALTER TABLE customers REPLICA IDENTITY USING INDEX customers_email_uk; -- or: ALTER TABLE customers REPLICA IDENTITY FULL; -- keyless fallback only -
Validate the emitted key without advancing the slot, by peeking at a test change:
sql -- Inspect one UPDATE's old-tuple in the stream before pointing a consumer at it. SELECT data FROM pg_logical_slot_peek_changes('cdc_slot', NULL, 1, 'proto_version', '4', 'publication_names', 'cdc_pub'); -
Revert. The change is symmetric and non-destructive:
ALTER TABLE customers REPLICA IDENTITY DEFAULT;restores the primary-key behavior instantly. Never switch a busy table toFULLwithout first checking its WAL impact under load — on a wide, frequently-updated row it can dominate total WAL generation.
Pipeline Integration
A CDC consumer must apply changes keyed on whatever old-tuple the identity emits, and it must handle the unchanged-TOAST case correctly or it will corrupt data. Under pgoutput, an out-of-line TOASTed column that an UPDATE did not modify is sent as an unchanged marker rather than its value; a consumer that treats an absent column as NULL will overwrite the stored value with null. The safe pattern is an upsert that only writes columns the event actually carried:
# Apply an UPDATE keyed on the replica-identity old key, preserving unchanged TOAST columns.
def apply_update(cur, event):
key_cols = event["old_key"] # from replica identity: PK, index cols, or full row
changed = event["new"] # only columns present in the pgoutput new-tuple
# Build SET only for columns actually delivered — never null an unchanged TOAST value.
set_clause = ", ".join(f"{c} = %({c})s" for c in changed)
where = " AND ".join(f"{c} = %(k_{c})s" for c in key_cols)
params = {**changed, **{f"k_{c}": v for c, v in key_cols.items()}}
cur.execute(f"UPDATE customers SET {set_clause} WHERE {where}", params)
Three rules keep this correct at scale. First, key both UPDATE and DELETE on the old-tuple the identity provides — do not assume the primary key is present, because under USING INDEX it is the index columns, and under FULL it is the whole prior row. Second, make apply idempotent: delivery is at-least-once, so a redelivered DELETE for an already-deleted row must be a no-op, and a replayed UPDATE must converge. Third, if you must run FULL, batch small and expect the extra WAL — export the per-table WAL contribution so an identity change does not silently blow your retention budget, correlating it with the generation rate covered in monitoring WAL generation and retention.
Failover handling. REPLICA IDENTITY is stored in the catalog and travels with the schema, so a logical subscriber inherits it, but a rebuilt or pg_dump-restored table can lose a USING INDEX identity if the index is recreated under a different name. After any failover or restore, re-run the identity audit above before resuming the stream, so a silently-reset identity does not turn every UPDATE into a NOTHING-style rejection.
Authoritative references
- PostgreSQL:
ALTER TABLE ... REPLICA IDENTITY— the four settings and their index requirements. - PostgreSQL: Logical Replication — Row Filters and Replica Identity — how identity interacts with published operations.
- PostgreSQL: Message Formats (
pgoutput) — the old-tuple and unchanged-TOAST encoding inUPDATE/DELETEmessages. - PostgreSQL:
pg_class(relreplident) — the catalog column the audits above read.
Related
- Logical vs physical replication differences — the model comparison this identity choice sits within.
- Publication/subscription models — the parent guide to the declarative objects that publish these changes.
- CDC pipeline implementation with Python and Debezium — consuming the old-tuple key this setting emits.