Skip to content
About
Main content

Restore test and time-to-restore

Run a real systemNeeds Docker, Python

Problem

A is a claim about the future: that the data can be brought back, to a chosen moment, within a time you can accept. Only a restore tests it. Point-in-time restores a base backup and replays archived up to a target time, so it depends on the base backup and on every WAL segment since. Its duration is the data volume divided by the restore rate, which is what turns "we have backups" into a recovery time.

Create a PostgreSQL database (Docker is enough) with about 1 GB of generated data. Take a base backup (pg_basebackup) with WAL archiving on. Insert 100,000 more rows, note the timestamp, then run DROP TABLE on one table. Restore to a scratch directory with point-in-time recovery (recovery_target_time set just before the drop). Compare row counts and a with the values you recorded before the drop. Time the whole restore and compute GB per second. Also try a restore from a backup you "forgot" to include WAL for. The script runs its own containers.

The question. Does the dropped table come back complete, how fast is the restore, and what happens without the WAL archive?


Predict

At this machine's restore rate, how long would 10 TB take?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    restore-test-and-time-to-restore.py100 linesDownload
    View the code
    restore-test-and-time-to-restore.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
    """Restore test and time to restore.
    
    A PostgreSQL database with WAL archiving and about 1 GB of data: a base
    backup, 100,000 more rows, a checksum, then DROP TABLE. A point-in-time
    restore to just before the drop: rows, checksum, restore time and GB/s.
    Then a restore from the same backup with the WAL archive missing.
    Runs its own containers with the docker CLI (needs Docker).
    """
    import argparse, json, subprocess, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=12_000_000, help="Rows in the bulk table (about 1 GB at the default).")
    p.add_argument("--image", default="postgres:17.6", help="PostgreSQL image.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    SRC, DST = "lab-pitr-src", "lab-pitr-restore"
    VOLS = ["lab-pitr-archive", "lab-pitr-backup", "lab-pitr-empty"]
    
    def sh(*a, check=True): return subprocess.run(["docker", *a], capture_output=True, text=True, check=check)
    def cleanup():
        sh("rm", "-f", SRC, DST, check=False)
        for v in VOLS: sh("volume", "rm", "-f", v, check=False)
    def wait_ready(port, timeout=600):
        t = time.time()
        while time.time() - t < timeout:
            try:
                c = psycopg.connect(f"postgresql://postgres:lab@localhost:{port}/postgres", autocommit=True, connect_timeout=2)
                if not c.execute("SELECT pg_is_in_recovery()").fetchone()[0]: return c
                c.close()
            except Exception: pass
            time.sleep(1)
        raise TimeoutError("database not ready")
    
    cleanup()
    for v in VOLS: sh("volume", "create", v)
    sh("run", "-d", "--name", SRC, "-e", "POSTGRES_PASSWORD=lab", "-p", "55510:5432",
       "-v", "lab-pitr-archive:/archive", "-v", "lab-pitr-backup:/backup", args.image,
       "-c", "wal_level=replica", "-c", "archive_mode=on", "-c", "archive_command=cp %p /archive/%f", "-c", "max_wal_size=4GB")
    time.sleep(3); sh("exec", "-u", "root", SRC, "chown", "-R", "postgres", "/archive", "/backup")
    con = wait_ready(55510)
    con.execute(f"CREATE TABLE bulk AS SELECT g AS id, md5(g::text) AS a, md5((g * 7)::text) AS b FROM generate_series(1, {args.rows}) g")
    con.execute("CREATE TABLE orders (id int PRIMARY KEY, amount numeric)")
    con.execute("INSERT INTO orders SELECT g, g % 997 FROM generate_series(1, 200000) g")
    db_gb = con.execute("SELECT pg_database_size('postgres')").fetchone()[0] / 1e9
    sh("exec", "-u", "postgres", SRC, "pg_basebackup", "-D", "/backup/base", "-X", "stream", "-c", "fast")
    con.execute("INSERT INTO orders SELECT g, g % 997 FROM generate_series(200001, 300000) g")       # 100,000 more after the backup
    CHECK = "SELECT count(*), md5(string_agg(o::text, '' ORDER BY id)) FROM orders o"
    before_rows, before_md5 = con.execute(CHECK).fetchone()
    target = con.execute("SELECT now()").fetchone()[0]
    time.sleep(1.5)
    con.execute("DROP TABLE orders")
    last = con.execute("SELECT pg_walfile_name(pg_switch_wal())").fetchone()[0]
    t0 = time.time()                                                       # wait until the archive really holds that segment
    while (con.execute("SELECT coalesce(last_archived_wal, '') FROM pg_stat_archiver").fetchone()[0] or "") < last:
        if time.time() - t0 > 300: raise TimeoutError("WAL archiving did not catch up")
        time.sleep(1)
    con.close(); sh("stop", "-t", "60", SRC)
    
    def restore(archive_vol):
        sh("rm", "-f", DST, check=False)
        script = ("cp -r /backup/base /tmp/pgdata && chmod 700 /tmp/pgdata && "
                  f"echo \"restore_command = 'cp /archive/%f %p'\" >> /tmp/pgdata/postgresql.auto.conf && "
                  f"echo \"recovery_target_time = '{target.isoformat()}'\" >> /tmp/pgdata/postgresql.auto.conf && "
                  "echo \"recovery_target_action = 'promote'\" >> /tmp/pgdata/postgresql.auto.conf && "
                  "touch /tmp/pgdata/recovery.signal && exec postgres -D /tmp/pgdata")
        t = time.time()
        sh("run", "-d", "--name", DST, "-u", "postgres", "-p", "55511:5432", "-v", "lab-pitr-backup:/backup", "-v", f"{archive_vol}:/archive",
           "--entrypoint", "bash", args.image, "-c", script)
        try:
            c = wait_ready(55511, timeout=300); secs = time.time() - t
            rows_, md5_ = c.execute(CHECK).fetchone(); c.close()
            return {"rows": rows_, "md5_match": "yes" if md5_ == before_md5 else "no", "seconds": secs, "error": ""}
        except Exception:
            log = sh("logs", DST, check=False).stderr.strip().splitlines()
            fatal = next((l for l in reversed(log) if "FATAL" in l or "recovery ended" in l), log[-1] if log else "")
            return {"rows": None, "md5_match": "no", "seconds": time.time() - t, "error": fatal.split("FATAL:")[-1].strip()[:160]}
    
    ok = restore("lab-pitr-archive")
    gbps = db_gb / ok["seconds"] if ok["seconds"] else None
    print(f"database {db_gb:.2f} GB; orders before the drop: {before_rows:,} rows")
    print(f"point-in-time restore: {ok['rows']} rows, checksum matches: {ok['md5_match']}, {ok['seconds']:.0f} s ({gbps:.3f} GB/s)")
    hours_10tb = 10_000 / gbps / 3600 if gbps else None
    print(f"at this rate 10 TB would take {hours_10tb:.1f} hours (500 MB/s would take 5.6)")
    nowal = restore("lab-pitr-empty")
    print(f"restore with the WAL archive missing: {nowal['error'] or str(nowal['rows']) + ' rows'}")
    cleanup()
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"restores": [{"restore": "with the WAL archive", **ok}, {"restore": "WAL archive missing", **nowal}],
                       "summary": [{"db_gb": db_gb, "rows_before": before_rows, "rows_after": ok["rows"], "md5_match": ok["md5_match"],
                                    "restore_s": ok["seconds"], "gbps": gbps, "hours_10tb": hours_10tb, "nowal_error": nowal["error"] or "none"}]}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run restore-test-and-time-to-restore.py
    uv run restore-test-and-time-to-restore.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows12,000,000 Rows in the bulk table (about 1 GB at the default).
--imagepostgres:17.6 PostgreSQL image.

Our run

Two restores of the same base backup

Two restores of the same base backup
RestoreRows backChecksum matchesTimeError
with the WAL archive300Kyes11.7 s—
WAL archive missing—no302 srecovery ended before configured recovery target was reached
Reference runApple M2 · 8 cores · 8 GB
Output · uv run restore-test-and-time-to-restore.py
database 1.23 GB; orders before the drop: 300,000 rows
point-in-time restore: 300000 rows, checksum matches: yes, 12 s (0.105 GB/s)
at this rate 10 TB would take 26.3 hours (500 MB/s would take 5.6)
restore with the WAL archive missing: recovery ended before configured recovery target was reached
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · run 2026-10-25

What we found

The point-in-time restore brought back 300K rows of 300K, checksum matching: yes. Restoring the 1.23 GB database took 11.7 s, 0.105 GB/s, so 10 TB would take 26.3 hours at this rate. Without the WAL archive, the restore failed: recovery ended before configured recovery target was reached.


Read it

Time to restore is a number you can only measure, and it belongs next to the backup's age on the dashboard. The missing-WAL case is the important one: the base backup was complete and green, and it could not reach the target time. A restore test is the only check that covers the whole chain.