What query-based CDC misses
Run a real systemBreak it on purposeNeeds Docker, Python
Problem
Query-based change capture polls a table for rows whose updated_at is newer than the last value seen. It sees the state at each poll, so it misses whatever happens between polls; it cannot see deletes, because a deleted row no longer matches any query; and it can miss a committed row entirely. In PostgreSQL, now() is the time the transaction started, so a transaction that sets updated_at = now() and commits ten seconds later writes a timestamp older than rows the poller has already passed. Log-based capture reads every change from the database's write-ahead log in commit order and sees all three.
Create a table in SQLite or PostgreSQL with id, status, updated_at. Run a poller that executes SELECT ... WHERE updated_at > :last_seen. In another session: (1) update one row three times between two polls; (2) delete a row; (3) begin a transaction, set updated_at = now(), wait 10 seconds, then commit, while the poller runs. Compare what the poller saw with the real history. If you use PostgreSQL, next try logical decoding with pg_recvlogical and wal2json or test_decoding on the same actions, and watch all three events appear.
The script uses PostgreSQL with a test_decoding slot and polls every second.
The question. Of three updates, a delete and a slow commit, what does the poller see, and what does logical decoding see?
Predict
Expect it to see only the last of the three updates, to see nothing of the delete, and to miss the slow-commit row if its high-water mark moved past the old timestamp. Logical decoding shows all three events.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
what-query-based-cdc-misses.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """What query-based CDC misses. A poller selecting updated_at > :last_seen every second, while another session (1) updates one row three times between two polls, (2) deletes a row, and (3) sets updated_at = now() in a transaction that commits 10 s later. Then the same actions read from a logical replication slot. Needs the PostgreSQL service from the compose file (port 55480). """ import argparse, json, threading, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55480/postgres", help="Connection string.") p.add_argument("--poll-every", type=float, default=1.0, help="Seconds between polls.") p.add_argument("--slow-commit", type=float, default=10.0, help="Seconds the slow transaction stays open.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() admin = psycopg.connect(args.dsn, autocommit=True) admin.execute("SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = 'cdc_lab'") admin.execute("DROP TABLE IF EXISTS items") admin.execute("CREATE TABLE items (id int PRIMARY KEY, status text, updated_at timestamptz NOT NULL DEFAULT clock_timestamp())") admin.execute("INSERT INTO items SELECT g, 'new', clock_timestamp() FROM generate_series(1, 5) g") time.sleep(0.2) admin.execute("SELECT pg_create_logical_replication_slot('cdc_lab', 'test_decoding')") seen, stop = [], threading.Event() def poller(): con = psycopg.connect(args.dsn, autocommit=True) last = con.execute("SELECT max(updated_at) FROM items").fetchone()[0] while not stop.is_set(): for rid, status, ts in con.execute("SELECT id, status, updated_at FROM items WHERE updated_at > %s ORDER BY updated_at", (last,)): seen.append({"id": rid, "status": status}); last = max(last, ts) stop.wait(args.poll_every) th = threading.Thread(target=poller); th.start() w = psycopg.connect(args.dsn, autocommit=True) time.sleep(1.5) # (3) the slow transaction: now() is the transaction's start time, and it commits much later slow = psycopg.connect(args.dsn) slow.execute("UPDATE items SET status = 'slow', updated_at = now() WHERE id = 3") # (1) three updates between two polls for s in ("paid", "packed", "shipped"): w.execute("UPDATE items SET status = %s, updated_at = clock_timestamp() WHERE id = 1", (s,)); time.sleep(0.05) time.sleep(args.poll_every * 2) # (2) a delete w.execute("DELETE FROM items WHERE id = 2") time.sleep(args.poll_every * 2) w.execute("UPDATE items SET status = 'later', updated_at = clock_timestamp() WHERE id = 4") # moves the high-water mark on time.sleep(max(0, args.slow_commit - 4 * args.poll_every)) slow.commit() # commits now, with a timestamp from 10 s ago time.sleep(args.poll_every * 3) stop.set(); th.join() changes = [r[0] for r in admin.execute("SELECT data FROM pg_logical_slot_get_changes('cdc_lab', NULL, NULL)")] log = [c for c in changes if c.startswith("table public.items")] actual = [("row 1, three updates", "statuses " + ", ".join(s["status"] for s in seen if s["id"] == 1) or "none", sum("id[integer]:1 " in c and "UPDATE" in c for c in log)), ("row 2, deleted", "seen" if any(s["id"] == 2 for s in seen) else "not seen", sum("DELETE" in c for c in log)), ("row 3, slow commit", "seen" if any(s["id"] == 3 for s in seen) else "missed", sum("id[integer]:3 " in c for c in log))] rows = [{"action": a, "poller": pv, "log_events": n} for a, pv, n in actual] for r in rows: print(f"{r['action']:<22} poller: {r['poller']:<34} logical decoding: {r['log_events']} event(s)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"actions": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
what-query-based-cdc-misses.compose.yml # PostgreSQL with logical decoding, for the query-based CDC lab. services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab command: ["postgres", "-c", "wal_level=logical", "-c", "max_replication_slots=4"] ports: ["55480:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f what-query-based-cdc-misses.compose.yml up -d --wait uv run what-query-based-cdc-misses.py uv run what-query-based-cdc-misses.py --help # every knob docker compose -f what-query-based-cdc-misses.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55480/postgres | Connection string. |
| --poll-every1 | Seconds between polls. |
| --slow-commit10 | Seconds the slow transaction stays open. |
Our run
The poller against the write-ahead log
| Action | Poller saw | Logical decoding events |
|---|---|---|
| row 1, three updates | statuses shipped | 3 |
| row 2, deleted | not seen | 1 |
| row 3, slow commit | missed | 1 |
row 1, three updates poller: statuses shipped logical decoding: 3 event(s)
row 2, deleted poller: not seen logical decoding: 1 event(s)
row 3, slow commit poller: missed logical decoding: 1 event(s)What we found
Of the three quick updates to row 1, the poller saw statuses shipped; logical decoding recorded 3 updates. The delete of row 2 was not seen by the poller and appeared as 1 event in the log. The slow commit to row 3 was missed: its timestamp was set when the transaction began, ten seconds before it became visible, and the poller's watermark had already moved past it. The log recorded it (1 event), in commit order.
Read it
Also create a replication slot, leave it unread, and watch pg_wal grow: that is the "paused analytics pipeline can fill the production disk" risk (the change-data-capture mechanism's log-retention lab measures it).
A watermark on a timestamp assumes rows become visible in timestamp order, and transactions break that assumption whenever they run longer than the polling interval. Pollers that must use timestamps subtract a safety margin from the watermark and deduplicate the overlap; the complete answer is to read the log.