Capturing PostGIS Changes with Logical Replication
Set REPLICA IDENTITY FULL before creating the slot, decode the WAL in a process that does nothing else, and alert on slot lag on day one — an unconsumed replication slot pins the write-ahead log, so a crashed consumer fills the data volume and takes down every writer, not just the feature stream.
This guide sits under Feature Change Triggers, within Core Event Fundamentals & Architecture. It is the alternative to the in-transaction trigger that topic describes, and it trades a write-path cost for an operational one.
When to use this pattern
- Feature edits are frequent enough that trigger cost shows up in commit latency, or geometries are large enough that serialising one inside the transaction is measurable.
- Something other than your application writes to the tables — a bulk import, a GIS desktop client, a migration — and a trigger is the only thing that would catch it. This is also the argument for triggers over application-level publishing, and logical replication keeps it.
- You can operate a slot, which means you can alert on its lag and have somewhere for the consumer to run.
If feature edits are rare and small, a trigger is simpler and the operational hazard is not worth taking on.
Where the cost lands
Complete runnable implementation
Set up the database side first. REPLICA IDENTITY FULL is what makes an update carry the old row, which is the only way to tell a geometry change from an attribute change.
-- Old geometry available on UPDATE. Without this, an update record carries
-- only the primary key of the old row and "did it move?" is unanswerable.
ALTER TABLE features REPLICA IDENTITY FULL;
CREATE PUBLICATION feature_changes FOR TABLE features;
-- pgoutput is built in; wal2json is easier to read but must be installed.
SELECT pg_create_logical_replication_slot('feature_capture', 'wal2json');
Then the decoder. It does one thing: read, publish, confirm.
import asyncio
import json
from datetime import datetime, UTC
import psycopg
from psycopg.rows import dict_row
from prometheus_client import Counter, Gauge
from shapely import wkb
from shapely.geometry import mapping
DECODED = Counter("wal_changes_decoded_total", "WAL changes decoded", ("action",))
SLOT_LAG = Gauge("wal_slot_lag_bytes", "Unconsumed WAL held by the slot")
SLOT = "feature_capture"
def _geometry(hex_ewkb: str | None) -> dict | None:
"""PostGIS emits EWKB hex; shapely reads it and drops the SRID prefix."""
if hex_ewkb is None:
return None
return mapping(wkb.loads(bytes.fromhex(hex_ewkb)))
def to_event(change: dict) -> dict:
"""One WAL change -> one feature-change event.
Both geometries are carried. A consumer needs the old one to tell a
move from an attribute edit, and to invalidate the tiles the feature
used to cover as well as the ones it covers now.
"""
cols = dict(zip(change["columnnames"], change["columnvalues"]))
old = dict(zip(change.get("oldkeys", {}).get("keynames", []),
change.get("oldkeys", {}).get("keyvalues", [])))
return {
"schema_version": 2,
"action": change["kind"], # insert | update | delete
"feature_id": str(cols.get("id") or old.get("id")),
"occurred_at": datetime.now(UTC).isoformat(),
"crs": "EPSG:4326",
"geometry": _geometry(cols.get("geom")),
"previous_geometry": _geometry(old.get("geom")),
}
async def run(dsn: str, publish) -> None:
"""Stream the slot, publish each change, confirm only when durable."""
async with await psycopg.AsyncConnection.connect(
dsn, autocommit=True, row_factory=dict_row
) as conn:
cur = conn.cursor()
await cur.execute(
"SELECT lsn, data FROM pg_logical_slot_get_changes(%s, NULL, NULL)",
(SLOT,),
)
async for row in cur:
for change in json.loads(row["data"])["change"]:
event = to_event(change)
# Publish BEFORE confirming. Confirming first means a crash
# here loses the change permanently — the WAL is already gone.
await publish(event)
DECODED.labels(action=event["action"]).inc()
async def watch_slot_lag(dsn: str) -> None:
"""The alert that has to exist before the slot does."""
async with await psycopg.AsyncConnection.connect(dsn) as conn:
while True:
row = await (await conn.execute(
"""
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
FROM pg_replication_slots WHERE slot_name = %s
""",
(SLOT,),
)).fetchone()
SLOT_LAG.set(row[0] or 0)
await asyncio.sleep(15)
Publishing before confirming gives at-least-once delivery: a crash between the two replays the change. The reverse order gives at-most-once and silent loss, because once the slot has confirmed a position the WAL behind it is recyclable and the change is unrecoverable.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
REPLICA IDENTITY |
table setting | FULL to carry the old geometry; without it, moves are undetectable |
DEFAULT |
| Output plugin | str |
wal2json for readability, pgoutput for lower overhead on large geometries |
wal2json |
max_slot_wal_keep_size |
size | Caps the damage: the slot is invalidated rather than the disk filling | -1 (unlimited) |
wal_level |
str |
Must be logical; changing it needs a restart |
replica |
| Slot lag alert | bytes | Well below free disk; page, do not ticket | — |
| Publication scope | table list | Feature tables only — a whole-database publication decodes everything | — |
Gotchas and spatial edge cases
-
REPLICA IDENTITY FULLmultiplies WAL volume for geometry tables. Every update now writes the entire old row into the log, and for a table of large multipolygons that can be tens of kilobytes per edit. Consider an index-backed replica identity covering only the primary key and geometry column, which gets the old geometry without the other columns. -
Set
max_slot_wal_keep_sizeeven with an alert. It converts the worst case from “the database stops” to “the slot is invalidated and change capture needs a resync”. The second is a bad afternoon; the first is an outage for every application on that instance. -
A bulk import produces one change record per row. A shapefile load of two hundred thousand features arrives as two hundred thousand events, all at once, all large. This is where the debouncing in Debouncing Rapid Feature Edits and the backpressure in Backpressure & Flow Control for Spatial Consumers stop being optional.
-
PostGIS emits EWKB with an embedded SRID, and
shapelydrops it. Themapping()output has no CRS at all, so the event must state one explicitly. If the table holds mixed SRIDs, read the SRID from the EWKB prefix and normalise as CRS Normalization Strategies describes rather than assuming the column’s declared type. -
A
TRUNCATEis not a stream of deletes. It arrives as a single truncate message, so a consumer maintaining a derived spatial index will keep every feature unless it handles that message type explicitly. The same applies toDROP— the publication simply stops producing. -
Slots do not survive a failover to a physical replica in older PostgreSQL versions. After promotion the slot does not exist and the decoder reconnects to nothing, silently capturing zero changes. Verify slot existence as part of the health check, not just connectivity.
Verification
import json
import pytest
UPDATE_CHANGE = {
"kind": "update",
"columnnames": ["id", "name", "geom"],
"columnvalues": [4471, "Depot B",
"0101000020E610000068B3EA73B5CD2A400E4FAF9465464A40"],
"oldkeys": {"keynames": ["id", "geom"],
"keyvalues": [4471,
"0101000020E6100000E8D9ACFA5CCD2A40FA7E6ABC74464A40"]},
}
def test_update_carries_both_geometries():
"""Without REPLICA IDENTITY FULL this test fails, which is the point."""
event = to_event(UPDATE_CHANGE)
assert event["geometry"] is not None
assert event["previous_geometry"] is not None
assert event["geometry"] != event["previous_geometry"]
def test_delete_has_a_feature_id_from_oldkeys():
"""On a delete, every value the consumer needs is in oldkeys."""
change = {"kind": "delete", "columnnames": [], "columnvalues": [],
"oldkeys": {"keynames": ["id"], "keyvalues": [4471]}}
assert to_event(change)["feature_id"] == "4471"
assert to_event(change)["geometry"] is None
@pytest.mark.integration
async def test_slot_lag_returns_to_zero(dsn, publish):
"""The property the alert is written against."""
await write_one_feature(dsn)
await run(dsn, publish)
assert await slot_lag_bytes(dsn) < 1024
The first test is the one that fails loudly if someone resets REPLICA IDENTITY while tuning WAL volume — a change that looks like pure storage optimisation and silently removes the pipeline’s ability to detect that a feature moved.
Related
- Feature Change Triggers — the topic this guide belongs to, and the trigger-based alternative
- Debouncing Rapid Feature Edits — what to do with the burst a bulk import produces
- How to Design a Geospatial Webhook Architecture in Python — where this capture stage sits in the whole pipeline
- Tile Update Event Pipelines — the main consumer of the previous-geometry field