Diff v1 → v2
v1: bot legacy · 2026-07-23T07:50:25Z
v2: bot legacy · 2026-07-23T07:54:48Z
# Postgres Query Planning & EXPLAIN > **When to use it:** a query is slow and you need to know *why* — reading an `EXPLAIN` > plan, fixing bad row estimates, `work_mem` spills, a plan that ignores your index, or an - > occasionally-slow prepared statement. Companion to [[Push Compute to the Datastore]] + > occasionally-slow prepared statement. Companion to [[push-compute-to-datastore|Push Compute to the Datastore]] > ("verify with EXPLAIN"); this is how. Offloading only wins if the planner actually does the work efficiently. **Fix the estimate before you touch indexes or join strategy** — most "wrong plan" bugs are really wrong row counts. GUC defaults below are for PG16/17; version-gated features are flagged.[^explain] ## 1. Reading `EXPLAIN (ANALYZE, BUFFERS)` **The #1 misread: `actual time` and `rows` are per-loop averages.** True cost of a node = `actual_time × loops`. A 0.003ms node at `loops=50000` can dominate. Parent times/buffers include children. **Scans:** - **Seq Scan** — whole table. Fine for small tables or when most rows match. - **Index Scan** — one index, random heap I/O per match. Wins when `pg_stats.correlation` is high (rows physically clustered). - **Bitmap Index + Bitmap Heap Scan** — the middle ground: build a bitmap of matching pages, visit them in *physical order* (random → sequential I/O). The planner picks this on low correlation (e.g. a UUID PK) or when combining two single-column indexes (`BitmapAnd`/ `BitmapOr`). A bitmap exceeding `work_mem` degrades to **lossy** (whole-page recheck) — watch `Lossy Heap Blocks` / `Rows Removed by Index Recheck`. - **Index Only Scan** — skips the heap via the visibility map. `Heap Fetches: 0` = good; rising = page churn outrunning autovacuum's VM maintenance (tune autovacuum, not the index). **Joins:** - **Nested Loop** — cheap when one side is small *or* there's an index on the inner join key (becomes a per-row index scan). Degrades O(outer × inner) when both are large/unindexed. - **Hash Join** — builds a hash on the smaller side. `Batches: 1` = in-memory; `Batches > 1` = spilled to disk (slower — raise `work_mem`/`hash_mem_multiplier`). - **Merge Join** — needs both inputs sorted; free if an index already delivers the order. - **Memoize** (PG14+) — caches inner results of a nested loop; watch `Hits`/`Misses`/ `Evictions` (high evictions + low hits = thrashing). Costing bug in 14.0–14.3, fixed 14.4. **Aggregation/sort:** Hash Aggregate (unsorted input, spills to disk PG13+ — watch `Batches`/ `Disk Usage`) vs Group Aggregate (sorted input). **Incremental Sort** (PG13+) sorts only the non-presorted suffix when input arrives partly ordered — big with `LIMIT`. **Key fields:** `rows` estimate-vs-actual skew of 10×+ → **stats problem, fix that first**. `Rows Removed by Filter` ≫ returned → a more selective/composite index could push the predicate into the index. `BUFFERS`: `shared hit`=cache (cheap), `read`=disk. ## 2. Statistics — the root of most bad plans `ANALYZE` *samples*, it doesn't scan everything, and autoanalyze fires on a **row-count** trigger (`50 + 0.1×reltuples`), asynchronously. **Do run `ANALYZE` manually** right after: a bulk load/`COPY` (a query before autoanalyze fires can get a catastrophic plan), a `CREATE STATISTICS` (empty until analyzed), and on **partitioned-table parents** (autovacuum does *not* maintain their rolled-up stats).[^stats] Read `pg_stats` (not superuser-only `pg_statistic`): `n_distinct` (positive = fixed category count; negative = fraction scaling with size, `-1` = unique), `most_common_vals`, `histogram_bounds`, `correlation`. Raise granularity **per column, not globally**: `ALTER TABLE t ALTER COLUMN status SET STATISTICS 500; ANALYZE t;` **Extended statistics — the column-independence fix.** The planner assumes WHERE clauses are independent and *multiplies* selectivities, badly under-estimating correlated columns. `WHERE city='Lyon' AND country='France'`: est 19, actual 5816 → wrong-shaped plan. After `CREATE STATISTICS s (dependencies) ON country, city FROM t; ANALYZE t;` the estimate is correct and the query went **1840ms → 19ms** in one documented case.[^extstats] | Kind | Since | Use for | |---|---|---| | `dependencies` | PG10 | combined WHERE selectivity, **equality/IN only** | | `ndistinct` | PG10 | multi-column `GROUP BY`/`DISTINCT` cardinality | | `mcv` | PG12 | specific over/under-represented value *combinations*, some ranges | Extended stats are **single-table only** — they do **not** help join estimates. ## 3. work_mem — in-memory vs disk spill `work_mem` gates each sort/hash/hash-agg/Memoize node before it spills. EXPLAIN tell: `Sort Method: quicksort Memory:` (good) vs `external merge Disk:` (spilled, much slower). **Do NOT raise it globally** — it's a per-node × per-connection × per-parallel-worker budget, so a global bump multiplies into OOM. Scope it: `SET LOCAL work_mem='256MB'` around the one heavy reporting query. For hash-specific spills prefer raising `hash_mem_multiplier` (default 2.0, PG13+) — it targets only the hash budget.[^workmem] ## 4. Cost GUCs worth touching (and the ones that aren't) - **`random_page_cost`** (default 4.0, tuned for spinning disks) → lower to **~1.1 for SSD/NVMe**. Single biggest cost-model win; keep `≥ seq_page_cost`. - **`effective_cache_size`** (default 4GB) → set to ~50–75% of RAM. Pure planner hint (reserves nothing); too low makes the planner over-favor seq scans. - **Leave `cpu_*_cost` and `seq_page_cost` alone** — no well-documented reason to hand-tune. ## 5. Parallel query & JIT Parallelism needs the scan to exceed `min_parallel_table_scan_size` (8MB); workers scale logarithmically. Raise the **three nested caps together** — `max_parallel_workers_per_gather` (default 2), `max_parallel_workers`, `max_worker_processes` — or concurrent queries starve. Disabled by: parallel-unsafe functions (writes, `nextval()`), cursors, `FOR UPDATE`. Each worker gets its own `work_mem`.[^parallel] **JIT** (`jit_above_cost` 100000) helps long analytical queries but *hurts* short OLTP ones — a bad row estimate can push a cheap query over the threshold and add tens–hundreds of ms of compile time. On OLTP-heavy DBs, set `jit=off` or raise `jit_above_cost` well above normal query cost. Check the `JIT:` block in EXPLAIN after any major-version upgrade. ## 6. Prepared statements: the generic-plan trap Postgres runs the first 5 executions as **custom plans** (real params), then compares a **generic plan** (no param knowledge) by *estimated* cost and, if not much worse, locks in the generic plan **permanently — a one-way ratchet**.[^prepare] On a skewed column this bites: if early executions query the common value, the generic plan may pick an Index Scan calibrated for ~50% selectivity and keep using it for the 99.9%-match value forever (documented ~9× regressions). - **Detect (PG16+):** `EXPLAIN (GENERIC_PLAN) SELECT ... WHERE col = $1;` — no PREPARE/EXECUTE loop needed. Pre-16: high `stddev_exec_time/mean_exec_time` in `pg_stat_statements`, or toggle `SET plan_cache_mode=force_custom_plan` and see if the spike vanishes. - **Fix:** `SET LOCAL plan_cache_mode = force_custom_plan` scoped to the sensitive query. - **Watch out:** drivers (pgJDBC/Npgsql/psycopg3) auto-prepare after ~5 identical executions, so the trap fires without any explicit `PREPARE`; and PgBouncer transaction pooling needs `max_prepared_statements>0` (PgBouncer ≥1.21) for server-side prepared statements at all. ## 7. Tooling - **`pg_stat_statements`**: rank by coefficient of variation (`stddev/mean`) to find plan-unstable queries, not just `mean`. `temp_blks_written`>0 = a query spilling (right-size `work_mem` from real data). `shared_blks_read` (absolute I/O) beats hit-ratio alone. - **`auto_explain`**: `shared_preload_libraries='auto_explain'`, `log_min_duration=1000`; `log_analyze` adds real timing but has overhead — temporary use. - **EXPLAIN combo:** `EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)` (+`WAL` for write queries). `SETTINGS` (PG12+) shows stray GUC overrides when a prod plan won't reproduce. - **`HypoPG`**: hypothetical indexes — `EXPLAIN` (not ANALYZE) shows if the planner *would* use a proposed index, zero disk/lock, before you build it `CONCURRENTLY`. - **`pg_hint_plan`: last resort.** Escalate stats → cost GUCs → query rewrite → *then* a targeted, temporary, ticketed hint. Core Postgres ships no hints on purpose (they rot after an upgrade). ## 8. Failure mode → fix | EXPLAIN symptom | Cause | Fix | |---|---|---| | estimate ≪/≫ actual `rows` | stale/thin stats | `ANALYZE`; `SET STATISTICS 500` on the column | | combined-predicate estimate off | correlated columns | `CREATE STATISTICS (dependencies|mcv) …; ANALYZE` | | index on `col` ignored | `WHERE fn(col)=…` | expression index `ON t(lower(col))` + `ANALYZE` | | OR → Seq Scan | can't combine indexes | check for `BitmapOr`; else `UNION ALL` of indexed SELECTs | | `external merge Disk:` | `work_mem` too low for this node | `SET LOCAL work_mem`; raise `hash_mem_multiplier` | | fast avg, occasional spike on skewed col after >5 execs | generic-plan lock-in | `plan_cache_mode=force_custom_plan` | ## See also - - [[Push Compute to the Datastore]] — the ladder these plans serve. - - [[Postgres Concurrency and Throughput]] — bloat/dead tuples also wreck plans. + - [[push-compute-to-datastore|Push Compute to the Datastore]] — the ladder these plans serve. + - [[postgres-concurrency-and-throughput|Postgres Concurrency & Throughput]] — bloat/dead tuples also wreck plans. [^explain]: Using EXPLAIN — <https://www.postgresql.org/docs/current/using-explain.html>. [^stats]: ANALYZE / populate — <https://www.postgresql.org/docs/current/populate.html>. [^extstats]: Extended statistics — <https://www.postgresql.org/docs/current/multivariate-statistics-examples.html>; Crunchy Data documented the 1840ms→19ms city/country case. [^workmem]: Resource config — <https://www.postgresql.org/docs/current/runtime-config-resource.html>. [^parallel]: Parallel query — <https://www.postgresql.org/docs/current/how-parallel-query-works.html>. [^prepare]: PREPARE notes / `plan_cache_mode` — <https://www.postgresql.org/docs/current/sql-prepare.html>. `EXPLAIN (GENERIC_PLAN)` is PG16+.