Skip to content
About
Main content

Scan resistance in a real database (PostgreSQL)

Run a real systemNeeds Docker, Python

Problem

A large sequential scan touches every page of a table once, and in a plain pool it would evict everything else. PostgreSQL protects the pool by giving large sequential scans a small private ring of buffers (256 KB), so the scan recycles its own buffers instead of the pool's. Other access paths get no ring: reading the same table through an index in random order pulls its pages through the shared pool like any other query.

Use PostgreSQL with pg_buffercache, shared_buffers = 128MB, and a table about eight times larger. Warm a small hot table with point lookups and count its pages in the pool. Run a full sequential scan of the big table and count again. Then warm again and read the big table through an index in random order (no ) and count again. Record the time of a point lookup after each.

The question. How much of the hot table survives a sequential scan, and how much survives the same table read through an index?


Predict

After the big table is read through an index in random order, what share of the hot table is still in the pool?

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.

    scan-resistance-in-a-real-database.py66 linesDownload
    View the code
    scan-resistance-in-a-real-database.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
    """Scan resistance in a real database.
    
    PostgreSQL with shared_buffers = 128 MB, a small hot table and a big table
    about eight times the buffer pool. Warm the hot table with point lookups and
    count its pages in the pool (pg_buffercache); read the whole big table with a
    sequential scan and count again; then warm again and read the big table
    through an index in random order (no ring buffer) and count again. Times a
    pass of point lookups on the hot table after each read.
    Needs the PostgreSQL service from the compose file (port 55601).
    """
    import argparse, json, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55601/postgres", help="Connection string.")
    p.add_argument("--big-rows", type=int, default=4_000_000, help="Rows in the big table (about 250 bytes each).")
    p.add_argument("--hot-rows", type=int, default=20_000, help="Rows in the hot table.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    c = psycopg.connect(args.dsn, autocommit=True)
    c.execute("CREATE EXTENSION IF NOT EXISTS pg_buffercache")
    c.execute("DROP TABLE IF EXISTS hot_table, big_table")
    c.execute("CREATE TABLE hot_table (id int PRIMARY KEY, v text)")
    c.execute(f"INSERT INTO hot_table SELECT g, repeat('h', 80) FROM generate_series(1, {args.hot_rows}) g")
    c.execute("CREATE TABLE big_table (id int, rnd double precision, pad text)")
    c.execute(f"INSERT INTO big_table SELECT g, random(), repeat('b', 200) FROM generate_series(1, {args.big_rows}) g")
    c.execute("CREATE INDEX ON big_table (rnd)"); c.execute("VACUUM ANALYZE hot_table, big_table")
    
    def q(sql): return c.execute(sql).fetchone()[0]
    hot_pages = q("SELECT pg_relation_size('hot_table') / 8192")
    big_mb = q("SELECT pg_relation_size('big_table')") / 2**20
    pool_mb = q("SELECT setting::bigint * 8192 / 1048576 FROM pg_settings WHERE name = 'shared_buffers'")
    def resident(): return q("""SELECT count(*) FROM pg_buffercache b JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
                                WHERE c.relname = 'hot_table' AND b.relforknumber = 0""")
    def lookups():
        t = time.perf_counter()
        for i in range(1, args.hot_rows + 1, 7): c.execute("SELECT v FROM hot_table WHERE id = %s", (i,)).fetchone()
        return (time.perf_counter() - t) / len(range(1, args.hot_rows + 1, 7)) * 1e6
    def warm():
        c.execute("SELECT count(v) FROM hot_table"); lookups(); lookups()
    
    rows = []
    warm(); rows.append({"step": "hot table warmed", "hot_pages_resident": resident(), "lookup_us": lookups()})
    t = time.perf_counter(); q("SELECT count(*) FROM big_table"); seq_s = time.perf_counter() - t
    rows.append({"step": "after a sequential scan of the big table", "hot_pages_resident": resident(), "lookup_us": lookups(), "read_s": seq_s})
    warm(); rows.append({"step": "hot table warmed again", "hot_pages_resident": resident(), "lookup_us": lookups()})
    c.execute("SET enable_seqscan = off"); c.execute("SET enable_bitmapscan = off")
    plan = "\n".join(r[0] for r in c.execute("EXPLAIN SELECT count(pad) FROM big_table WHERE rnd >= 0").fetchall())
    t = time.perf_counter(); q("SELECT count(pad) FROM big_table WHERE rnd >= 0"); idx_s = time.perf_counter() - t
    rows.append({"step": "after reading the big table through an index (random order)", "hot_pages_resident": resident(), "lookup_us": lookups(), "read_s": idx_s})
    for r in rows: r["hot_resident_pct"] = r["hot_pages_resident"] / hot_pages * 100
    summary = {"hot_pages": hot_pages, "big_mb": big_mb, "pool_mb": pool_mb, "index_scan": "Index Scan" in plan}
    print(f"buffer pool {pool_mb} MB, big table {big_mb:.0f} MB, hot table {hot_pages} pages")
    for r in rows: print(f"{r['step']:<62} hot pages resident {r['hot_pages_resident']:>4}/{hot_pages} ({r['hot_resident_pct']:5.1f}%)   point lookup {r['lookup_us']:6.1f} µs")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"steps": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    scan-resistance-in-a-real-database.compose.yml13 linesDownload
    View the code
    scan-resistance-in-a-real-database.compose.yml
    # PostgreSQL with a small buffer pool for the scan-resistance lab.
    services:
      postgres:
        image: postgres:17.6
        command: ["postgres", "-c", "shared_buffers=128MB", "-c", "max_parallel_workers_per_gather=0"]
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55601: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 scan-resistance-in-a-real-database.compose.yml up -d --wait
    uv run scan-resistance-in-a-real-database.py
    uv run scan-resistance-in-a-real-database.py --help     # every knob
    docker compose -f scan-resistance-in-a-real-database.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55601/postgres Connection string.
--big-rows4,000,000 Rows in the big table (about 250 bytes each).
--hot-rows20,000 Rows in the hot table.

Our run

Hot-table pages in the buffer pool

Hot-table pages in the buffer pool
StepHot pages residentPoint lookup (µs)
hot table warmed100%140
after a sequential scan of the big table100%145
hot table warmed again100%145
after reading the big table through an index (random order)0%146
Reference runApple M2 · 8 cores · 8 GB
Output · uv run scan-resistance-in-a-real-database.py
buffer pool 128 MB, big table 977 MB, hot table 286 pages
hot table warmed                                               hot pages resident  286/286 (100.0%)   point lookup  139.5 µs
after a sequential scan of the big table                       hot pages resident  286/286 (100.0%)   point lookup  145.1 µs
hot table warmed again                                         hot pages resident  286/286 (100.0%)   point lookup  144.8 µs
after reading the big table through an index (random order)    hot pages resident    0/286 (  0.0%)   point lookup  145.7 µs
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

After the sequential scan of the 977 MB table, 100% of the hot table's 286 pages were still in the 128 MB pool. After reading the same table through the index, 0% were. Point lookups barely changed (145 and 146 µs), because the evicted pages were still in the operating system's and the time was dominated by the client round trip.


Read it

Scan resistance is a property of the access path, not of the pool. The same table read in a different way can still flush the cache, and in a real system with less spare memory the evicted pages would come from disk. Watch for large index scans and other random reads of big tables when hot-data latency degrades.