Skip to content
About
Main content

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 ). 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 . 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

Under the default plan_cache_mode, does PostgreSQL switch this prepared statement to a generic plan after five executions?

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.

    parameter-sniffing-in-postgresql.py44 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    parameter-sniffing-in-postgresql.compose.yml13 linesDownload
    View 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: 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 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
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55708/postgres Connection string.
--rows10,000,000 Rows.

Our run

Execution time of each EXECUTEApple M2 · 8 cores · 8 GB
autoforce_custom_planforce_generic_plan
Data table
Execution time of each EXECUTE
Execution (1–7 LU, 8 US, 9 LU)seriesTime (ms)
1auto0.046
2auto0.025
3auto0.023
4auto0.021
5auto0.02
6auto0.021
7auto0.02
8auto970
9auto0.041
1force_custom_plan0.024
2force_custom_plan0.022
3force_custom_plan0.024
4force_custom_plan0.021
5force_custom_plan0.022
6force_custom_plan0.023
7force_custom_plan0.021
8force_custom_plan960
9force_custom_plan0.036
1force_generic_plan1.22
2force_generic_plan0.873
3force_generic_plan0.742
4force_generic_plan0.764
5force_generic_plan0.704
6force_generic_plan0.694
7force_generic_plan0.656
8force_generic_plan775
9force_generic_plan0.923
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

Each execution

Each execution
plan_cache_mode#ValuePlanTime (ms)
auto1LUcustom, index-only scan0.046
auto2LUcustom, index-only scan0.025
auto3LUcustom, index-only scan0.023
auto4LUcustom, index-only scan0.021
auto5LUcustom, index-only scan0.02
auto6LUcustom, index-only scan0.021
auto7LUcustom, index-only scan0.02
auto8UScustom, seq scan970
auto9LUcustom, index-only scan0.041
force_custom_plan1LUcustom, index-only scan0.024
force_custom_plan2LUcustom, index-only scan0.022
force_custom_plan3LUcustom, index-only scan0.024
force_custom_plan4LUcustom, index-only scan0.021
force_custom_plan5LUcustom, index-only scan0.022
force_custom_plan6LUcustom, index-only scan0.023
force_custom_plan7LUcustom, index-only scan0.021
force_custom_plan8UScustom, seq scan960
force_custom_plan9LUcustom, index-only scan0.036
force_generic_plan1LUgeneric, index-only scan1.22
force_generic_plan2LUgeneric, index-only scan0.873
force_generic_plan3LUgeneric, index-only scan0.742
force_generic_plan4LUgeneric, index-only scan0.764
force_generic_plan5LUgeneric, index-only scan0.704
force_generic_plan6LUgeneric, index-only scan0.694
force_generic_plan7LUgeneric, index-only scan0.656
force_generic_plan8USgeneric, index-only scan775
force_generic_plan9LUgeneric, index-only scan0.923
Output · uv run parameter-sniffing-in-postgresql.py
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 ms

What 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.