Skip to content
About
Main content

Read-your-writes with a replica, and a lost token

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

Reading from spreads load, but a replica applies changes a little after the them. A user who saves a profile and immediately reads it back from a replica can see the old value: is broken. The fix is a session token: after the write, remember the primary's log position (in PostgreSQL, its WAL ); before reading from a replica, wait until the replica has replayed past that position, or read from the primary. The guarantee lives in the token, so it holds only as long as every request carries it.

Run a PostgreSQL primary and one streaming replica (two containers with the official image is enough). Write a small script that updates a row on the primary and immediately reads it from the replica 1,000 times; count how many reads are stale. Add pg_sleep on the replica's replay (or recovery_min_apply_delay = '200ms') to make the lag visible. Then implement the token: after the write, record pg_current_wal_lsn(); before reading from the replica, wait until pg_last_wal_replay_lsn() >= token (or read from the primary after a timeout). Finally, "break" the token by sending the read through a path that drops it and show the stale reads return.

The compose file starts the primary and a replica cloned from it; the script sets the replica's apply delay to 200 ms and drops the token on half of the requests in the last part.

The question. How many immediate reads from the lagging replica are stale, how many with the token, and what happens when the token is lost on the way?


Predict

With a 200 ms apply delay and no token, what share of immediate reads from the replica are stale?

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.

    read-your-writes-with-a-replica.py91 linesDownload
    View the code
    read-your-writes-with-a-replica.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
    """Read your writes with a replica.
    
    Update a row on a PostgreSQL primary and read it straight back from a
    streaming replica that applies changes with a delay. Then carry a token
    (the write's log position) and make the replica catch up to it before
    reading; then lose the token on the way, as a request routed elsewhere would.
    Needs the primary and replica from the compose file (ports 55470, 55471).
    """
    import argparse, json, random, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--primary", default="postgresql://postgres:lab@localhost:55470/postgres", help="Primary connection string.")
    p.add_argument("--replica", default="postgresql://postgres:lab@localhost:55471/postgres", help="Replica connection string.")
    p.add_argument("--delay-ms", type=int, default=200, help="recovery_min_apply_delay on the replica.")
    p.add_argument("--reads", type=int, default=1000, help="Write-then-read pairs without the token.")
    p.add_argument("--token-reads", type=int, default=200, help="Write-then-read pairs with the token (each may wait for the replica).")
    p.add_argument("--timeout", type=float, default=2.0, help="Seconds to wait for the replica before reading the primary instead.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    
    pri = psycopg.connect(args.primary, autocommit=True)
    rep = psycopg.connect(args.replica, autocommit=True)
    rep.execute(f"ALTER SYSTEM SET recovery_min_apply_delay = '{args.delay_ms}ms'")
    rep.execute("SELECT pg_reload_conf()")
    pri.execute("DROP TABLE IF EXISTS profile")
    pri.execute("CREATE TABLE profile (id int PRIMARY KEY, name text)")
    pri.execute("INSERT INTO profile VALUES (1, 'v0')")
    time.sleep(args.delay_ms / 1000 + 1)
    
    def write(i):
        pri.execute("UPDATE profile SET name = %s WHERE id = 1", (f"v{i}",))
        return pri.execute("SELECT pg_current_wal_lsn()").fetchone()[0]          # the token
    
    def read_replica():
        return rep.execute("SELECT name FROM profile WHERE id = 1").fetchone()[0]
    
    def wait_for(token):
        t = time.perf_counter()
        while time.perf_counter() - t < args.timeout:
            if rep.execute("SELECT pg_last_wal_replay_lsn() >= %s::pg_lsn", (token,)).fetchone()[0]:
                return time.perf_counter() - t, "replica"
            time.sleep(0.005)
        return time.perf_counter() - t, "primary"
    
    # 1. No token: write on the primary, read straight back from the replica
    stale = 0
    for i in range(1, args.reads + 1):
        write(i)
        stale += read_replica() != f"v{i}"
    
    # 2. The token: wait until the replica has replayed the write
    stale_tok, waits, fallbacks, n = 0, [], 0, args.reads
    for i in range(n + 1, n + args.token_reads + 1):
        token = write(i)
        waited, src = wait_for(token)
        waits.append(waited); fallbacks += src == "primary"
        value = read_replica() if src == "replica" else pri.execute("SELECT name FROM profile WHERE id = 1").fetchone()[0]
        stale_tok += value != f"v{i}"
    
    # 3. The token lost: half the reads go through a path that drops it (another endpoint, a new session)
    stale_lost, n = 0, n + args.token_reads
    for i in range(n + 1, n + args.token_reads + 1):
        token = write(i)
        if random.random() < 0.5: wait_for(token)       # this request still carries the token
        stale_lost += read_replica() != f"v{i}"
    
    print(f"replica applies with a {args.delay_ms} ms delay")
    print(f"no token:          {stale:>5} of {args.reads} reads stale ({stale / args.reads:.1%})")
    print(f"with the token:    {stale_tok:>5} of {args.token_reads} stale; mean wait {sum(waits) / len(waits) * 1000:.0f} ms, "
          f"{fallbacks} fell back to the primary")
    print(f"token lost on half the requests: {stale_lost:>3} of {args.token_reads} stale ({stale_lost / args.token_reads:.1%})")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"variants": [
                {"variant": "no token", "reads": args.reads, "stale": stale, "stale_pct": stale / args.reads * 100},
                {"variant": "token", "reads": args.token_reads, "stale": stale_tok, "stale_pct": stale_tok / args.token_reads * 100},
                {"variant": "token lost on half the requests", "reads": args.token_reads, "stale": stale_lost,
                 "stale_pct": stale_lost / args.token_reads * 100}],
                "summary": [{"delay_ms": args.delay_ms, "stale_pct": stale / args.reads * 100, "token_stale": stale_tok,
                             "token_wait_ms": sum(waits) / len(waits) * 1000, "lost_pct": stale_lost / args.token_reads * 100}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    read-your-writes-with-a-replica.compose.yml33 linesDownload
    View the code
    read-your-writes-with-a-replica.compose.yml
    # A PostgreSQL primary and one streaming replica, cloned with pg_basebackup when it starts.
    configs:
      hba:
        content: |
          local all all trust
          host all all all trust
          host replication all all trust
    services:
      primary:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
          POSTGRES_HOST_AUTH_METHOD: trust
        command: ["postgres", "-c", "wal_level=replica", "-c", "hba_file=/etc/pg_hba.conf"]
        configs: [{ source: hba, target: /etc/pg_hba.conf }]
        ports: ["55470:5432"]
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 1s
          retries: 30
      replica:
        image: postgres:17.6
        user: postgres
        depends_on:
          primary: { condition: service_healthy }
        entrypoint: ["bash", "-c", "pg_basebackup -h primary -U postgres -D /tmp/replica -R -X stream && chmod 700 /tmp/replica && exec postgres -D /tmp/replica -c hot_standby=on -c hba_file=/etc/pg_hba.conf"]
        configs: [{ source: hba, target: /etc/pg_hba.conf }]
        ports: ["55471:5432"]
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 1s
          retries: 60
    
  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 read-your-writes-with-a-replica.compose.yml up -d --wait
    uv run read-your-writes-with-a-replica.py
    uv run read-your-writes-with-a-replica.py --help     # every knob
    docker compose -f read-your-writes-with-a-replica.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--primarypostgresql://postgres:lab@localhost:55470/postgres Primary connection string.
--replicapostgresql://postgres:lab@localhost:55471/postgres Replica connection string.
--delay-ms200 recovery_min_apply_delay on the replica.
--reads1000 Write-then-read pairs without the token.
--token-reads200 Write-then-read pairs with the token (each may wait for the replica).
--timeout2 Seconds to wait for the replica before reading the primary instead.
--seed0 Random seed.

Our run

Write on the primary, read from the replica

Write on the primary, read from the replica
VariantReadsStaleStale share
no token1,0001,000100%
token20000%
token lost on half the requests20010753.5%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run read-your-writes-with-a-replica.py
replica applies with a 200 ms delay
no token:           1000 of 1000 reads stale (100.0%)
with the token:        0 of 200 stale; mean wait 206 ms, 0 fell back to the primary
token lost on half the requests: 107 of 200 stale (53.5%)
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

With a 200 ms apply delay, 100% of immediate reads from the replica were stale. With the token, none were (0 stale), at the price of a 206 ms wait per read, about the replica's lag. When half the requests lost the token, 53.5% of reads were stale again, as expected: the requests that lost it behaved exactly as if there were no token.


Read it

This measures the and shows why the guarantee does not survive an endpoint change.

are implemented by client tokens and broken by infrastructure changes. A new load balancer, a retry through another region, a background job that reads on the user's behalf, a cache in between: each is a path that may not carry the token, and on that path the guarantee is simply absent. The alternative is (the user's reads go to the primary for a while after a write), which the next lab measures.