Parameter sniffing in PostgreSQL
Run a real systemNeeds Docker, Python
Problem
A prepared statement can be planned for each set of parameter values (a custom plan) or once for all of them (a generic plan). PostgreSQL plans the first five executions with the actual values, then compares their average cost with a generic plan's estimate and keeps whichever looks cheaper. On a skewed column the best plan depends on the value: a rare value wants the index, a common one wants a sequential scan, and one plan for both is mediocre for at least one of them.
Create a table with 10 million rows where country is 'US' for 95% and 'LU' for 0.002%, with an index on country and fresh statistics. Prepare PREPARE q(text) AS SELECT count(*) FROM t WHERE country = $1;. Run EXPLAIN (ANALYZE) EXECUTE q('LU') six or more times, then EXECUTE q('US'). Watch whether the plan switches from a custom plan to a generic plan, with SET plan_cache_mode = force_custom_plan and force_generic_plan to compare.
The question. Does the plan switch to a generic plan after the first executions, and how do custom and generic plans compare for each value?
Predict
Expect the generic plan to be mediocre for both values and the custom plan to be best for each.
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
parameter-sniffing-in-postgresql.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 """Parameter sniffing in PostgreSQL. A 10-million-row table where country is 'US' for 95% of rows and 'LU' for 0.002%, indexed and analyzed. A prepared statement is executed with 'LU' six or more times, then with 'US', under plan_cache_mode auto, force_custom_plan and force_generic_plan: which plan each execution used, and its time. Needs the PostgreSQL service from the compose file (port 55708). """ import argparse, json, re import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55708/postgres", help="Connection string.") p.add_argument("--rows", type=int, default=10_000_000, help="Rows.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() c = psycopg.connect(args.dsn, autocommit=True, prepare_threshold=None) # the driver must not prepare statements of its own c.execute("DROP TABLE IF EXISTS t") c.execute(f"""CREATE TABLE t AS SELECT g AS id, CASE WHEN g % 50000 = 0 THEN 'LU' WHEN g % 20 = 0 THEN 'FR' ELSE 'US' END AS country FROM generate_series(1, {args.rows}) g""") c.execute("CREATE INDEX ON t (country)"); c.execute("VACUUM ANALYZE t") rows = [] for mode in ("auto", "force_custom_plan", "force_generic_plan"): c.execute("DEALLOCATE ALL"); c.execute(f"SET plan_cache_mode = {mode}") c.execute("PREPARE q(text) AS SELECT count(*) FROM t WHERE country = $1") for i, val in enumerate(["LU"] * 7 + ["US", "LU"]): txt = "\n".join(r[0] for r in c.execute(f"EXPLAIN (ANALYZE) EXECUTE q('{val}')").fetchall()) generic = "$1" in txt scan = "index-only" if "Index Only" in txt else "index" if "Index Scan" in txt or "Bitmap" in txt else "seq" if "Seq Scan" in txt else "?" rows.append({"mode": mode, "execution": i + 1, "value": val, "plan": ("generic" if generic else "custom") + f", {scan} scan", "ms": float(re.search(r"Execution Time: ([\d.]+)", txt).group(1))}) for r in rows: print(f"{r['mode']:<18} #{r['execution']} {r['value']}: {r['plan']:<24} {r['ms']:8.2f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"executions": 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
parameter-sniffing-in-postgresql.compose.yml # PostgreSQL for the parameter-sniffing-in-postgresql lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_parallel_workers_per_gather=0"] environment: POSTGRES_PASSWORD: lab ports: ["55708: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 parameter-sniffing-in-postgresql.compose.yml up -d --wait uv run parameter-sniffing-in-postgresql.py uv run parameter-sniffing-in-postgresql.py --help # every knob docker compose -f parameter-sniffing-in-postgresql.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55708/postgres | Connection string. |
| --rows10,000,000 | Rows. |
Our run
Data table
| Execution (1–7 LU, 8 US, 9 LU) | series | Time (ms) |
|---|---|---|
| 1 | auto | 0.046 |
| 2 | auto | 0.025 |
| 3 | auto | 0.023 |
| 4 | auto | 0.021 |
| 5 | auto | 0.02 |
| 6 | auto | 0.021 |
| 7 | auto | 0.02 |
| 8 | auto | 970 |
| 9 | auto | 0.041 |
| 1 | force_custom_plan | 0.024 |
| 2 | force_custom_plan | 0.022 |
| 3 | force_custom_plan | 0.024 |
| 4 | force_custom_plan | 0.021 |
| 5 | force_custom_plan | 0.022 |
| 6 | force_custom_plan | 0.023 |
| 7 | force_custom_plan | 0.021 |
| 8 | force_custom_plan | 960 |
| 9 | force_custom_plan | 0.036 |
| 1 | force_generic_plan | 1.22 |
| 2 | force_generic_plan | 0.873 |
| 3 | force_generic_plan | 0.742 |
| 4 | force_generic_plan | 0.764 |
| 5 | force_generic_plan | 0.704 |
| 6 | force_generic_plan | 0.694 |
| 7 | force_generic_plan | 0.656 |
| 8 | force_generic_plan | 775 |
| 9 | force_generic_plan | 0.923 |
Each execution
| plan_cache_mode | # | Value | Plan | Time (ms) |
|---|---|---|---|---|
| auto | 1 | LU | custom, index-only scan | 0.046 |
| auto | 2 | LU | custom, index-only scan | 0.025 |
| auto | 3 | LU | custom, index-only scan | 0.023 |
| auto | 4 | LU | custom, index-only scan | 0.021 |
| auto | 5 | LU | custom, index-only scan | 0.02 |
| auto | 6 | LU | custom, index-only scan | 0.021 |
| auto | 7 | LU | custom, index-only scan | 0.02 |
| auto | 8 | US | custom, seq scan | 970 |
| auto | 9 | LU | custom, index-only scan | 0.041 |
| force_custom_plan | 1 | LU | custom, index-only scan | 0.024 |
| force_custom_plan | 2 | LU | custom, index-only scan | 0.022 |
| force_custom_plan | 3 | LU | custom, index-only scan | 0.024 |
| force_custom_plan | 4 | LU | custom, index-only scan | 0.021 |
| force_custom_plan | 5 | LU | custom, index-only scan | 0.022 |
| force_custom_plan | 6 | LU | custom, index-only scan | 0.023 |
| force_custom_plan | 7 | LU | custom, index-only scan | 0.021 |
| force_custom_plan | 8 | US | custom, seq scan | 960 |
| force_custom_plan | 9 | LU | custom, index-only scan | 0.036 |
| force_generic_plan | 1 | LU | generic, index-only scan | 1.22 |
| force_generic_plan | 2 | LU | generic, index-only scan | 0.873 |
| force_generic_plan | 3 | LU | generic, index-only scan | 0.742 |
| force_generic_plan | 4 | LU | generic, index-only scan | 0.764 |
| force_generic_plan | 5 | LU | generic, index-only scan | 0.704 |
| force_generic_plan | 6 | LU | generic, index-only scan | 0.694 |
| force_generic_plan | 7 | LU | generic, index-only scan | 0.656 |
| force_generic_plan | 8 | US | generic, index-only scan | 775 |
| force_generic_plan | 9 | LU | generic, index-only scan | 0.923 |
auto #1 LU: custom, index-only scan 0.05 ms
auto #2 LU: custom, index-only scan 0.03 ms
auto #3 LU: custom, index-only scan 0.02 ms
auto #4 LU: custom, index-only scan 0.02 ms
auto #5 LU: custom, index-only scan 0.02 ms
auto #6 LU: custom, index-only scan 0.02 ms
auto #7 LU: custom, index-only scan 0.02 ms
auto #8 US: custom, seq scan 970.19 ms
auto #9 LU: custom, index-only scan 0.04 ms
force_custom_plan #1 LU: custom, index-only scan 0.02 ms
force_custom_plan #2 LU: custom, index-only scan 0.02 ms
force_custom_plan #3 LU: custom, index-only scan 0.02 ms
force_custom_plan #4 LU: custom, index-only scan 0.02 ms
force_custom_plan #5 LU: custom, index-only scan 0.02 ms
force_custom_plan #6 LU: custom, index-only scan 0.02 ms
force_custom_plan #7 LU: custom, index-only scan 0.02 ms
force_custom_plan #8 US: custom, seq scan 959.67 ms
force_custom_plan #9 LU: custom, index-only scan 0.04 ms
force_generic_plan #1 LU: generic, index-only scan 1.22 ms
force_generic_plan #2 LU: generic, index-only scan 0.87 ms
force_generic_plan #3 LU: generic, index-only scan 0.74 ms
force_generic_plan #4 LU: generic, index-only scan 0.76 ms
force_generic_plan #5 LU: generic, index-only scan 0.70 ms
force_generic_plan #6 LU: generic, index-only scan 0.69 ms
force_generic_plan #7 LU: generic, index-only scan 0.66 ms
force_generic_plan #8 US: generic, index-only scan 774.83 ms
force_generic_plan #9 LU: generic, index-only scan 0.92 msWhat we found
Under the default mode the plan never switched: all nine executions used custom plans (custom, index-only scan for LU, custom, seq scan for US), because the custom plans for LU were so cheap that the generic estimate never looked better. Forced generic, LU ran as an index-only scan in 0.656 ms instead of 0.021 ms, and US also used the index-only scan, in 775 ms against 960 ms for the custom sequential scan. The generic plan was slower for LU and, on this run, faster for US: an index-only scan on a freshly vacuumed table reads only the index, which is smaller than the table.
Read it
Whether a prepared statement switches to a generic plan depends on cost estimates for the values it happens to see first, so the same application can behave differently depending on which customers arrive after a restart. Forcing custom plans makes every execution pay for planning and gets the best plan per value; forcing generic plans removes planning cost and accepts one plan for every value. On skewed columns, measure both on the values that matter rather than trusting the switch.