EXPLAIN MEMORY and Planning Buffers #

A point lookup executes in 60 microseconds and takes 37 milliseconds end to end. Nothing in the plan tree explains the gap, because the time is not in the tree: it is in producing it. Planning a query over a heavily partitioned table reads thousands of catalog pages and allocates hundreds of megabytes, and two options make that visible.

The wider option set is covered in EXPLAIN options and output formats. This page is about measuring the planner itself.

The Condition #

Two pieces of output describe planning rather than execution:

Together with Planning Time, they turn “planning is slow” into a measurement with a cause. Planning cost grows with:

A query planned once and executed for a long time need not care. A query planned thousands of times a second, or one planned repeatedly because its statements are not prepared, pays this cost every time.

Where planning time goes Four stages. Parse and analyze resolves objects through the catalog. Path generation builds candidate scans and joins for every relation and index. Join order search explores orders, growing with relation count. Plan construction allocates the final tree. Catalog reads and memory allocation happen throughout. parse + resolve catalog lookups build paths per relation and index search join orders grows with relations build the plan allocate the tree EXPLAIN reports the buffers and memory this consumed

Annotated EXPLAIN Evidence #

EXPLAIN (ANALYZE, BUFFERS, MEMORY, SUMMARY)
SELECT id, payload FROM events WHERE occurred_at = '2026-09-16 10:00:00+00' AND device_id = 4821;
Append  (actual time=0.03..0.04 rows=1 loops=1)
  ->  Index Scan using events_2026_09_16_device_idx on events_2026_09_16 events_1  (actual rows=1 loops=1)
        Buffers: shared hit=4
Planning:
  Buffers: shared hit=18402
  Memory: used=148204kB  allocated=152064kB
Planning Time: 37.104 ms
Execution Time: 0.061 ms
-- 18,402 catalog buffers and 148 MB of planner memory for a four-buffer query
-- planning is 600× the execution cost

A complex join, where the join search dominates:

Planning:
  Buffers: shared hit=2104
  Memory: used=41204kB  allocated=42496kB
Planning Time: 612.408 ms
Execution Time: 2170.902 ms
-- 14 relations in one join list: the search itself is expensive
-- see whether geqo_threshold applies at this relation count

And a healthy ratio:

Planning:
  Buffers: shared hit=112
  Memory: used=1204kB  allocated=1280kB
Planning Time: 0.412 ms
Execution Time: 84.206 ms
-- planning is 0.5% of the statement: nothing to do
Reading the planning block Three items are annotated. Planning time compared with execution time gives the ratio that decides whether to act. Planning buffers in the thousands indicate many relations or indexes examined. Planner memory in the hundreds of megabytes indicates a very large plan search, typically from partitions. Planning Time 37.1 ms vs 0.06 ms execution planning dominates Planning: Buffers: shared hit=18402 many relations examined Memory: used=148204kB large plan search, usually partitions compare the two times before optimising anything in the tree

Step-by-Step Resolution #

  1. Capture with SUMMARY and BUFFERS — and MEMORY on PostgreSQL 17+ — for the workload’s most frequent statements, not only its slowest.

  2. Compute the ratio. Planning above about 10% of execution deserves attention for frequently executed statements; above 100% it is the problem.

  3. Identify the driver from the buffers and memory: thousands of catalog buffers point at relation count (partitions, views expanding to many tables); high memory with moderate buffers points at the join search.

  4. For partition-driven planning cost, reduce the partition count or reuse plans, following planning overhead with many partitions.

  5. For join-search cost, check the relation count against geqo_threshold and the collapse limits; raising limits improves plans but increases planning time, and the trade-off is measured exactly here.

  6. Prune unused indexes. Every index on a referenced relation is a candidate path considered during planning, so unused indexes cost planning time as well as write time.

  7. Reuse plans with prepared statements where the workload allows, so the cost is paid once per session rather than per execution.

What drives planning time here With 1,460 partitions planning takes 37 milliseconds. Consolidating to 52 partitions brings it to 0.4 milliseconds. Removing four unused indexes from each partition brings it to 0.3. Using a prepared statement amortises it to effectively zero per execution after the first. 1,460 partitions 37 ms 52 partitions 0.4 ms + fewer indexes 0.3 ms + prepared statement ≈0 after first the same query and the same data in every bar

Before and After #

-- BEFORE: 1,460 partitions, planned on every execution
Planning: Buffers: shared hit=18402  Memory: used=148204kB   Planning Time: 37.104 ms

-- AFTER: 52 partitions, prepared statement reused
Planning: Buffers: shared hit=412    Memory: used=3204kB     Planning Time: 0.402 ms (first execution only)

Planning cost in aggregate #

A single statement’s planning time is rarely alarming; the workload’s total is. pg_stat_statements tracks planning separately when pg_stat_statements.track_planning is enabled, which makes it possible to rank statements by total planning time across the workload:

SELECT calls, round(total_plan_time) AS plan_ms, round(total_exec_time) AS exec_ms, left(query, 60)
FROM pg_stat_statements
WHERE total_plan_time > 0
ORDER BY total_plan_time DESC LIMIT 10;

That ranking often surprises. A statement planned in 3 ms and executed in 1 ms, called 200 times a second, spends more CPU planning than a nightly report spends executing. Enabling the tracking has a small overhead of its own, so it is usually turned on temporarily for an investigation rather than left on permanently.

The remedy list is short and ordered: fewer relations and indexes per query, plan reuse, and — only when neither is possible — accepting the cost and provisioning CPU for it.

Planner memory as a capacity signal #

The MEMORY figures are worth watching for a reason beyond speed. Planner memory is allocated per backend, per statement, and freed when the statement completes — but while a hundred backends each plan a query over a thousand partitions, a hundred allocations of a hundred megabytes exist simultaneously. That is enough to exhaust a machine whose memory budget was calculated from shared_buffers and work_mem alone, and the failure looks like an out-of-memory event with no obviously large query.

Two habits keep it visible. Capture MEMORY for the statements with the largest relation counts, not only the slowest, and multiply the figure by realistic concurrency when sizing the machine. And treat a planner allocation above a few tens of megabytes as a design signal: it almost always means a query is being planned over far more relations than it needs, which is a schema question rather than a memory one.

Common Pitfalls #

Optimising the plan tree when planning dominates. The tree is already fast. Diagnostic signal: execution time in microseconds, planning in milliseconds. Fix: reduce planning work.

Ignoring frequently executed statements. Planning cost scales with call rate. Diagnostic signal: high total planning time for a fast statement. Fix: rank by total, not per call.

Adding indexes without counting the cost. Each is a candidate path at planning time. Diagnostic signal: planning time rising after index additions. Fix: remove unused indexes.

Assuming prepared statements always help. They do not when plans are invalidated frequently or when custom plans are forced. Diagnostic signal: planning time unchanged despite preparation. Fix: check pg_prepared_statements counters.

Frequently Asked Questions #

How do I measure planning cost in PostgreSQL? #

Use EXPLAIN with SUMMARY for Planning Time, BUFFERS for the planner’s catalog reads, and — from PostgreSQL 17 — MEMORY for the memory the planner allocated. Compare planning time with execution time for the statement’s real call rate.

What makes planning slow? #

Many relations (including partitions and tables reached through views), many indexes per relation, large join searches, partitionwise planning and extended statistics. Each adds candidate paths or catalog lookups.

Does preparing statements remove planning cost? #

It amortises it: a cached generic plan is built once per session and reused, so frequently executed statements stop paying per execution. Plans are re-planned when invalidated, so the saving depends on how stable the schema and statistics are.

Up: EXPLAIN Options and Output Formats