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 LRU 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 ring buffer) 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
Expected: the sequential scan leaves the hot set almost intact; the other access pattern evicts a large part of it.
Run it
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 | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 304Start 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
| Knob · default | What 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
| Step | Hot pages resident | Point lookup (µs) |
|---|---|---|
| hot table warmed | 100% | 140 |
| after a sequential scan of the big table | 100% | 145 |
| hot table warmed again | 100% | 145 |
| after reading the big table through an index (random order) | 0% | 146 |
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 µsWhat 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 page cache 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.