Skip to content
About
Main content

Watch the log retention problem happen

Run a real systemNeeds Docker, Python

Problem

Log-based reads the database's (WAL), the record of every change it makes. In PostgreSQL a consumer holds its place with a replication slot, and the database promises not to delete any WAL that slot has not yet confirmed. That promise has a cost that points backwards: if the consumer stops reading, the source keeps every byte of log from that point on, and its disk fills. The downstream consumer's health becomes the upstream database's availability problem.

Start a PostgreSQL container with a logical replication slot (SELECT pg_create_logical_replication_slot('lab', 'test_decoding');). Do not read from it. Run a script that inserts and updates rows continuously, and check the retained log size every minute:

SELECT slot_name,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;

The script runs the writer for four minutes, measures every 15 seconds, then drops the slot and forces a checkpoint.

The question. How fast does the log retained for an unread slot grow, what happens when the slot is dropped, and how long would a real disk last?


Predict

At this writer's rate, how long would a 100 GB disk last with the slot unread?

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.

    watch-the-log-retention-problem-happen.py67 linesDownload
    View the code
    watch-the-log-retention-problem-happen.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
    """Watch the log retention problem happen.
    
    A logical replication slot that nobody reads, while a writer inserts and
    updates rows: the retained log grows until the slot is dropped.
    Needs the PostgreSQL service from the compose file (port 55432).
    """
    import argparse, json, threading, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55432/postgres", help="Connection string.")
    p.add_argument("--minutes", type=float, default=4.0, help="How long the writer runs with the slot unread.")
    p.add_argument("--sample-every", type=float, default=15.0, help="Seconds between measurements of the retained log.")
    p.add_argument("--disk-gb", type=float, default=100.0, help="Free disk to project the time-to-full against.")
    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 = 'lab'")
    admin.execute("DROP TABLE IF EXISTS orders")
    admin.execute("CREATE TABLE orders (order_id bigserial PRIMARY KEY, status text, total numeric, updated_at timestamptz DEFAULT now())")
    admin.execute("SELECT pg_create_logical_replication_slot('lab', 'test_decoding')")     # created, never read
    
    RETAINED = """SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) FROM pg_replication_slots WHERE slot_name = 'lab'"""
    WAL_DIR = "SELECT sum(size) FROM pg_ls_waldir()"
    
    stop = threading.Event()
    def writer():
        con = psycopg.connect(args.dsn, autocommit=True)
        while not stop.is_set():
            with con.transaction():
                con.execute("INSERT INTO orders (status, total) SELECT 'placed', random() * 100 FROM generate_series(1, 200)")
                con.execute("UPDATE orders SET status = 'paid', updated_at = now() WHERE order_id % 50 = (extract(epoch FROM now())::int % 50)")
    
    w = threading.Thread(target=writer); w.start()
    samples, t0 = [], time.time()
    while time.time() - t0 < args.minutes * 60:
        time.sleep(args.sample_every)
        retained = admin.execute(RETAINED).fetchone()[0]
        samples.append({"minute": (time.time() - t0) / 60, "retained_mb": float(retained) / 1e6,
                        "wal_dir_mb": float(admin.execute(WAL_DIR).fetchone()[0]) / 1e6})
        print(f"{samples[-1]['minute']:5.2f} min   retained by the slot: {samples[-1]['retained_mb']:8.1f} MB   "
              f"pg_wal on disk: {samples[-1]['wal_dir_mb']:8.1f} MB")
    stop.set(); w.join()
    
    growth = (samples[-1]["retained_mb"] - samples[0]["retained_mb"]) / (samples[-1]["minute"] - samples[0]["minute"])  # MB/min
    before = samples[-1]["wal_dir_mb"]
    admin.execute("SELECT pg_drop_replication_slot('lab')")
    admin.execute("CHECKPOINT"); admin.execute("SELECT pg_switch_wal()"); admin.execute("CHECKPOINT")
    after = float(admin.execute(WAL_DIR).fetchone()[0]) / 1e6
    hours = args.disk_gb * 1e3 / growth / 60
    print(f"\ngrowth while unread: {growth:,.1f} MB per minute = {growth * 60 / 1e3:,.1f} GB per hour")
    print(f"slot dropped + checkpoint: pg_wal {before:,.1f} MB -> {after:,.1f} MB")
    print(f"at this rate a {args.disk_gb:g} GB disk fills in {hours:,.1f} hours ({hours / 24:,.1f} days)")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"samples": samples, "summary": [{
                "growth_mb_per_min": growth, "wal_before_mb": before, "wal_after_mb": after,
                "hours_to_full": hours, "days_to_full": hours / 24, "disk_gb": args.disk_gb}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    watch-the-log-retention-problem-happen.compose.yml13 linesDownload
    View the code
    watch-the-log-retention-problem-happen.compose.yml
    # PostgreSQL with logical decoding switched on, for the replication-slot lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        command: ["postgres", "-c", "wal_level=logical", "-c", "max_replication_slots=4", "-c", "max_wal_size=64MB"]
        ports: ["55432: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 watch-the-log-retention-problem-happen.compose.yml up -d --wait
    uv run watch-the-log-retention-problem-happen.py
    uv run watch-the-log-retention-problem-happen.py --help     # every knob
    docker compose -f watch-the-log-retention-problem-happen.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55432/postgres Connection string.
--minutes4 How long the writer runs with the slot unread.
--sample-every15 Seconds between measurements of the retained log.
--disk-gb100 Free disk to project the time-to-full against.

Our run

Log retained for an unread replication slotApple M2 · 8 cores · 8 GB
Retained by the slotpg_wal on disk
Data table
Log retained for an unread replication slot
MinutesRetained by the slotpg_wal on disk
0.25394403
0.59891,010
0.751,7001,710
12,6002,620
1.253,9904,010
1.55,5105,540
1.766,5406,560
2.047,4107,430
2.298,2208,240
2.549,0809,110
2.799,7609,780
3.0510.4K10.5K
3.311.3K11.3K
3.5511.9K11.9K
3.812.5K12.6K
4.0513.4K13.4K
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20
Output · uv run watch-the-log-retention-problem-happen.py
 0.25 min   retained by the slot:    393.6 MB   pg_wal on disk:    402.7 MB
 0.50 min   retained by the slot:    988.7 MB   pg_wal on disk:   1006.6 MB
 0.75 min   retained by the slot:   1699.3 MB   pg_wal on disk:   1711.3 MB
 1.00 min   retained by the slot:   2601.1 MB   pg_wal on disk:   2617.2 MB
 1.25 min   retained by the slot:   3987.4 MB   pg_wal on disk:   4009.8 MB
 1.50 min   retained by the slot:   5514.4 MB   pg_wal on disk:   5536.5 MB
 1.76 min   retained by the slot:   6537.8 MB   pg_wal on disk:   6559.9 MB
 2.04 min   retained by the slot:   7410.2 MB   pg_wal on disk:   7432.3 MB
 2.29 min   retained by the slot:   8221.9 MB   pg_wal on disk:   8237.6 MB
 2.54 min   retained by the slot:   9080.5 MB   pg_wal on disk:   9110.0 MB
 2.79 min   retained by the slot:   9759.0 MB   pg_wal on disk:   9781.1 MB
 3.05 min   retained by the slot:  10446.2 MB   pg_wal on disk:  10469.0 MB
 3.30 min   retained by the slot:  11252.2 MB   pg_wal on disk:  11274.3 MB
 3.55 min   retained by the slot:  11872.1 MB   pg_wal on disk:  11895.0 MB
 3.80 min   retained by the slot:  12543.6 MB   pg_wal on disk:  12566.1 MB
 4.05 min   retained by the slot:  13399.7 MB   pg_wal on disk:  13421.8 MB

growth while unread: 3,423.4 MB per minute = 205.4 GB per hour
slot dropped + checkpoint: pg_wal 13,421.8 MB -> 67.1 MB
at this rate a 100 GB disk fills in 0.5 hours (0.0 days)

What we found

The retained log grew steadily for as long as the writer ran, at 3,420 MB per minute, and the WAL directory on disk grew with it: the server kept every segment the slot had not confirmed, even though max_wal_size was set to 64 MB. Dropping the slot and forcing a checkpoint brought pg_wal from 13.4K MB down to 67.1 MB.

At this rate a 100 GB disk would fill in 0.487 hours. The test writer inserts and updates as fast as it can, so its rate is high; a quieter database fills the disk more slowly, but just as surely, and the clock runs from the moment the consumer stops.


Read it

This is consumer liveness as a source-side risk. A replication slot is a promise with no expiry, so a crashed, paused or forgotten consumer turns into a full disk on the primary. The mitigations all make that risk visible and bounded: alert on the age of the oldest unconsumed log position, cap how much log the source may retain for one consumer (PostgreSQL's max_slot_wal_keep_size), accepting that the consumer must then re-bootstrap, and drop slots nobody owns.