Skip to content
About
Main content

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 ten seconds later writes a timestamp older than rows the poller has already passed. Log-based capture reads every change from the database's 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 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

Row 3 commits ten seconds after its timestamp was set. Does the poller ever see it?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    what-query-based-cdc-misses.py72 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    what-query-based-cdc-misses.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

The poller against the write-ahead log
ActionPoller sawLogical decoding events
row 1, three updatesstatuses shipped3
row 2, deletednot seen1
row 3, slow commitmissed1
Reference runApple M2 · 8 cores · 8 GB
Output · uv run what-query-based-cdc-misses.py
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)
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-25

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 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.