Plan cache, cost-constant tuning, and planner internals
Prepared statements switch to a generic plan after 5 executions — a trap for skewed parameters. SSD systems need random_page_cost = 1.1. GEQO handles large joins. JIT helps CPU-bound queries. Each mechanism has a specific failure mode and fix.
A parameterised query has been fast for months. One day it starts showing P99 latency of 4 seconds. Mean is still 3ms. The same query, different parameters — the distribution is skewed. After 5 executions Postgres switched to a generic plan that does not know the parameter values. One ALTER ROLE line fixes it.
The planner’s internal structure
Before you can reason about why the planner picked a surprising plan for a complex query, you need to understand what it actually compares — paths, not plans.
For each query the planner builds paths — lightweight, partial plans for portions of the query (single-table scans, joins of small relation sets). Paths are compared by cost; only the cheapest path per “relation set + sort order” is retained (the rest are pruned). For multi-relation joins, it considers all combinations using dynamic programming — exhaustively up to geqo_threshold (default 12) relations, then switches to the Genetic Query Optimizer (heuristic search) above that threshold.
The senior mental model: when you see a surprising plan, ask “what other paths did the planner consider, and why were they pruned?” The answer is almost always a cost difference driven by row estimates.
Generic vs custom plans (prepared statements)
Every parameterised query from a driver ($1, $2, …) and every PL/pgSQL function with parameters goes through plan caching. The lifecycle:
- First 5 executions: Postgres plans each with the actual parameter values — custom plans, optimal for that specific parameter.
- After 5th execution: Postgres computes the average cost of the 5 custom plans and compares it to the cost of a generic plan (parameter-agnostic).
- If generic cost is within ~10% of the average custom cost: Postgres switches permanently to the generic plan for the lifetime of the prepared statement.
All three steps together mean the switch is automatic, invisible, and permanent — until the connection closes or you intervene. The switch is invisible to the application. For uniformly distributed parameters, the generic plan is usually fine. For skewed distributions (workspace_id where some workspaces have 8% of rows and others have 0.01%), the generic plan optimises for a “typical” parameter and is catastrophically wrong for outliers.
Symptoms: high stddev_exec_time in pg_stat_statements (bimodal latency — fast for common parameters, slow for outliers).
Diagnosis (PG 16+): EXPLAIN (GENERIC_PLAN) on the prepared form — shows exactly what plan all executions after the switch get.
Fix:
ALTER ROLE app SET plan_cache_mode = 'force_custom_plan';Every execution replans with the actual parameter. Planning cost: ~0.4–2ms per execution — negligible when the alternative is 4-second tail latency.
A prepared statement that was fast is now slow — diagnose
# pg_stat_statements row for the offending query:
query: "SELECT * FROM orders WHERE workspace_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 50"
calls: 482,910
total_exec_time: 1,852,400 ms
mean_exec_time: 3.84 ms
stddev_exec_time: 412.6 ms # HUGE stddev -- symptom of plan switching
min_exec_time: 0.8 ms
max_exec_time: 4,290 ms
# EXPLAIN (ANALYZE, BUFFERS) on the SAME query with workspace_id=42 (high-volume):
Limit (cost=0..14.2 rows=50) (actual time=2.1..2.3 rows=50 loops=1)
-> Index Scan using idx_orders_workspace_status_created on orders
(cost=0..14000 rows=50000) (actual time=2.1..2.3 rows=50 loops=1)
Index Cond: ((workspace_id=42) AND (status='pending'::text))
Buffers: shared hit=12
Planning Time: 0.4 ms
Execution Time: 2.4 ms
# EXPLAIN (GENERIC_PLAN, BUFFERS) on the prepared form (PG 16+):
Limit (cost=0.43..28.5 rows=50)
-> Index Scan using idx_orders_created on orders
(cost=0.43..15000.0 rows=27000)
Filter: ((workspace_id = $1) AND (status = $2))
Planning Time: 0.2 ms
# Context:
# workspace_id distribution: workspace 1 has 0.01% of rows; workspace 42 has 8%.
# status distribution: 'pending' 15%, 'shipped' 80%, 'cancelled' 5%. Why does the same prepared statement show 3.84ms mean but 412ms stddev? What is the immediate fix?
SSD cost-constant tuning
Postgres ships with random_page_cost = 4.0 and seq_page_cost = 1.0 — calibrated for spinning HDDs where random reads are 4× slower than sequential. On NVMe SSDs the ratio is 1.5–2×; on working-set-fits-in-RAM systems it approaches 1.0.
-- In postgresql.conf for SSD-backed systems:
random_page_cost = 1.1
seq_page_cost = 1.0
-- Tell the planner how much data is cached by the OS:
effective_cache_size = '24GB' -- ~75% of total RAMEffect: the planner becomes more willing to use Index Scan and Index Only Scan over Seq Scan — which is the right bias for modern hardware. This is the single highest-leverage planner setting on any SSD-backed Postgres.
effective_cache_size is not a memory allocation — it is a hint telling the planner how much OS page-cache + shared_buffers is available. Setting it correctly makes the cost difference between cached index pages and uncached heap pages more accurate.
GEQO and large joins
For queries joining more than geqo_threshold (default 12) relations, the planner switches from exhaustive dynamic programming to the Genetic Query Optimizer — a heuristic that is fast but not guaranteed optimal. The heuristic is randomised — same query can produce different plans on different runs.
For analytic queries with 15–30 joined tables, GEQO can produce inconsistent plans. Workarounds:
-- Force exhaustive search (slower planning, stable plans):
SET geqo = off;
-- Lock the JOIN order to the SQL's explicit order:
SET from_collapse_limit = 1;
SET join_collapse_limit = 1;The from_collapse_limit = 1 trick is useful when you have hand-tuned a complex query’s JOIN order and want it to stay tuned across data changes.
JIT compilation
PG 11+ supports JIT compilation of expression evaluation (filter predicates, projections, aggregation). JIT kicks in when a plan’s total cost exceeds jit_above_cost (default 100,000).
JIT helps CPU-bound queries on big scans (millions of rows, complex expressions) by 10–30%. For short OLTP queries, it adds overhead — compilation time exceeds saved execution time.
-- For OLTP workloads where JIT hurts more than it helps:
SET jit_above_cost = 500000;
-- Or disable entirely for OLTP roles:
SET jit = off;Use EXPLAIN (ANALYZE, JIT) to see JIT timings per phase and decide whether to tune the thresholds.
- Custom plans before generic kicks in
- 5
- Generic plan cost tolerance vs custom avg
- ~10%
- geqo_threshold (default)
- 12 relations
- jit_above_cost (default)
- 100,000
- jit_inline_above_cost (default)
- 500,000
- from_collapse_limit / join_collapse_limit
- 8 / 8
- Planning time per execution (typical OLTP)
- 0.4-2 ms
- random_page_cost (SSD-tuned)
- 1.1
- effective_cache_size recommendation
- ~75% of total RAM
A prepared statement has been running fast for 5 executions. On the 6th execution it becomes slow. What is the most likely cause?
Which PostgreSQL 16 feature lets you inspect what plan all prepared-statement executions after the 6th will receive, without actually running 5 executions?
You set random_page_cost = 1.1 on an SSD-backed Postgres. What happens to plans for queries with selective predicates?
- 01Walk the full lifecycle of how Postgres picks a plan for a parameterised query, from first execution to the generic-plan switch.
- 02Why is effective_cache_size important for the planner even though it does not allocate any memory?
Postgres plan caching has a specific failure mode: after 5 executions of a prepared statement, Postgres compares the average custom-plan cost to a generic plan and may switch permanently to the generic plan. For skewed parameter distributions, the generic plan is optimal for a “typical” parameter and catastrophically slow for outliers — producing bimodal latency (low mean, high stddev). Diagnose with stddev_exec_time in pg_stat_statements and EXPLAIN (GENERIC_PLAN) (PG 16+). Fix with plan_cache_mode = 'force_custom_plan' for the role. For SSD-backed systems, set random_page_cost = 1.1 — the planner will correctly prefer Index Scan over Seq Scan for selective predicates. Now when you see a prepared statement that is fast most of the time but has wild tail latency, look at stddev_exec_time before anything else — that asymmetry is the generic-plan trap’s signature.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
appears again in177
- The journey of a request: seven stops from socket to responsejunior
- Accept and parse: from kernel queue to a typed requestmiddle
- Routing and middleware: choosing what runs, and in what ordermiddle
- Handler and response: from business logic to bytes on the wiremiddle
- Streaming and backpressure: when the client reads slower than you writesenior
- Timeouts and tail latency: budgets, deadlines, and the fan-out trapsenior
- Middleware and DI: the two patterns that shape every backendjunior
- Writing middleware: signatures, next(), and the three framework modelsmiddle
- Inversion of control: how dependencies reach a classmiddle
- DI scopes and lifecycles: singleton, request, transientmiddle
- DI as a testing seam: fakes, mocks, and the boundary that matterssenior
- DI containers in production: resolution graphs, circular deps, and when not tosenior
- Blocking vs non-blocking I/O: two ways to waitjunior
- The event loop: one thread, ordered phasesmiddle
- What blocks the loop: CPU work and sync callsmiddle
- Offloading CPU work: worker threads and the libuv poolmiddle
- Backpressure and bounded concurrencysenior
- Throughput under load: tail latency and saturationsenior
- Why pool: the cost of creating a connectionjunior
- Pool sizing: why bigger is not fastermiddle
- Acquisition and timeouts: the wait queue is the real latency dialmiddle
- Retry strategies: backoff, jitter, and thundering herdmiddle
- Observability, production failures, and global-scale designsenior
- Tasks, microtasks, and scheduler.yield()middle
- Timer accuracy, throttling, and idle workmiddle
- Node.js event loop: phases, nextTick, and loop lagsenior
- Rendering strategies: SSG, SSR, ISR, streaming, and hydrationjunior
- SSG, SSR, ISR, streaming, and RSC — how each worksmiddle
- Hydration cost: selective, progressive, islands, resumabilitymiddle
- Core Web Vitals: what LCP, INP, and CLS measurejunior
- LCP: four phases, one dominant costmiddle
- INP: input delay, processing, presentationmiddle
- Lab vs field: why the two disagree and how to use eachmiddle
- Metric tradeoffs, RUM attribution, and the CI+field loopsenior
- The full picture: URL to LCP to INP as a relay racejunior
- Eight layers traced: from the service worker to the second navigationmiddle
- Five canonical breaks: where production reliably diessenior
- The three-track method: reading traces and building a monitored systemsenior
- Generational GC and the Scavengermiddle
- Major GC: mark, sweep, compactsenior
- Write barriers: the price of incremental and generational GCsenior
- Bits on the wirejunior
- Latency mathmiddle
- Bufferbloat and congestionsenior
- The physical frontiersenior
- Sequence numbers and connection statemiddle
- Flow control and congestion controlmiddle
- BBR, production observability, and beyond TCPsenior
- CDN: putting content next doorjunior
- Anycast and GeoDNS: routing to the nearest edgemiddle
- Tiered cache and Cache-Controlmiddle
- Vary header and cache keysmiddle
- Stale-while-revalidate and cache stampedesenior
- Edge workers and edge-side compositionsenior
- CDN operations and observabilitysenior
- WebSocket: the HTTP upgrade handshakejunior
- WebSocket vs SSE vs long-polling: choosing the right transportmiddle
- WebSocket backpressure: when clients can''''t keep upmiddle
- Reconnection: jittered backoff, thundering herd, message resumptionsenior
- WebSocket at scale: HTTP/2 multiplexing, permessage-deflate, C10Msenior
- WebSocket in production: proxies, security, and distributed architecturesenior
- What reverse proxies dojunior
- Balancing algorithms: round-robin to power-of-two-choicesmiddle
- L4 vs L7 load balancing and client-IP preservationmiddle
- Health checks, connection draining, and slow startmiddle
- Retry storms, circuit breakers, and load sheddingsenior
- Resilient LB architecture: anycast, zone-aware routing, and observabilitysenior
- Why QUIC and not TCP+TLSjunior
- QUIC streams and head-of-line blockingjunior
- Integrated handshake and 1-RTTmiddle
- Connection IDs and network migrationmiddle
- Loss detection and congestion controlmiddle
- 0-RTT resumption and packet encryptionsenior
- Deployment tradeoffs and CPU costsenior
- DDoS: what it is and why it worksjunior
- Amplification attacks and state exhaustionmiddle
- Rate limiting: algorithms and architecturemiddle
- WAFs, firewalls, mTLS, and HSTSmiddle
- DNS cache poisoning and BGP hijackingsenior
- Defense-in-depth architecture and attack economicssenior
- The twelve layers: one URL, seven actorsjunior
- DNS, TCP, TLS in sequence: where the milliseconds gomiddle
- Critical render path and Core Web Vitalsmiddle
- Proxy intercepts and security gates: rate limiters, WAF, mTLSmiddle
- Alternate paths: QUIC 0-RTT, WebSocket upgrade, connection migrationmiddle
- Observability: distributed traces, USE/RED, and samplingsenior
- Resilience: cascading retries, circuit breakers, and error budgetssenior
- What the three signals are: logs, metrics, and tracesjunior
- Metrics and cardinality: the cost model of a time-series databasemiddle
- Logs and volume: the cost model of structured loggingmiddle
- Traces and sampling: the cost model of distributed tracingmiddle
- Join keys and exemplars: making the three signals composemiddle
- Observability 2.0: wide events and the cost shiftsenior
- Failure modes and engineering practice: cardinality budgets, PII, and samplingsenior
- Why structured logs exist: the diary vs the spreadsheetjunior
- The production log schema: fields every line must carrymiddle
- Log levels and alert routingmiddle
- Sampling strategies and log costmiddle
- PII redaction and log injectionsenior
- Trace context propagation in logssenior
- OTel Logs Data Model and audit logs as a subsystemsenior
- OTel signals, Semantic Conventions, and the OTLP wire formatmiddle
- Auto-instrumentation and manual spans: the 80/20 of OTelmiddle
- The OTel Collector: receivers, processors, exporters, and deployment patternsmiddle
- Sampling strategies: head, tail, and parent-basedmiddle
- Vendor neutrality, eBPF instrumentation, the Operator, and browser/serverless OTelsenior
- Operating the OTel Collector: reliability, version skew, failure modes, and governancesenior
- RED and USE: two checklists, one triage disciplinejunior
- Instrumenting RED in Prometheus: counters, histograms, and cardinality disciplinemiddle
- USE on Linux: CPU, memory, disk, network, and PSImiddle
- Golden signals, dashboard layout, and service mesh auto-REDmiddle
- Cardinality as a cost driver: labels, PII, exemplars, and samplingmiddle
- Native histograms, SLO tie-in, and production failure patternsmiddle
- Choosing SLIs and SLO targets: ratios, not feelingsmiddle
- Multi-window multi-burn-rate alerting: why AND beats ORmiddle
- Error budget policy, latency SLOs, and composite journeysmiddle
- Iceberg SLIs, composite SLO math, and SLA vs SLOsenior
- Flame graphs: reading the picture that shows where time goesjunior
- Sampling vs instrumentation profiling: why 99 Hz wins in productionmiddle
- Profile types: CPU, memory, off-CPU, mutex — which one to reach formiddle
- Continuous profiling: always-on flame graphs with eBPF and trace-id correlationmiddle
- How flame graphs are built from samples, and the production workflows that use themmiddle
- Linux perf, eBPF internals, PGO, and the limits of samplingsenior
- Profiling in production: security, war stories, OTel profiles, and the infrastructure designsenior
- The debugging funnel: SLO → RED → trace → profilejunior
- OTel architecture: one SDK, four signals, one wire formatmiddle
- Cost discipline: keeping observability under 5% of infra spendmiddle
- Scale, security, and the ROI of observable systemssenior
- Why profile first: measure where time actually goesjunior
- Amdahl''''s law and self-time: the ceiling on every speedup you can shipmiddle
- The measurement loop: microbench, macrobench, prod profile, observer effectmiddle
- Reading flame graphs: shapes, per-language profilers, and the 60-second scanmiddle
- Statistical baselines: why one run is not a measurementmiddle
- Profiler history and microbenchmark pitfalls: Knuth to GWPsenior
- Hardware counters, cold-start profiles, and profile securitysenior
- Continuous profiling at scale: costs, CI gates, trace correlation, and anti-patternssenior
- What makes a hot path: symptom vs causejunior
- Five shapes of hotspot: CPU, alloc, cache, lock, syscallmiddle
- Reading parent and child chains: where to apply the fixmiddle
- JIT deopt, the fix-and-verify loop, and PR-time profilingmiddle
- Hardware counters and Intel TMA: sub-category diagnosissenior
- False sharing and native-bridge hot pathssenior
- Hot paths in production: security, tail latency, and tooling lineagesenior
- Memory hierarchy: why the same O(N) loop can be 17x slowerjunior
- Row-major vs column-major: access order and the 9x gapjunior
- Branch prediction and branchless codemiddle
- Hardware prefetcher, TLB, and memory-level parallelismsenior
- GC basics: what the runtime taxes you forjunior
- GC algorithms: generational, concurrent, and per-runtimemiddle
- GC tradeoffs: pause, throughput, heap — and object poolingmiddle
- GC tuning: pacing, heap shape, and allocation observabilitymiddle
- GC internals: tri-color invariant, write barriers, and per-runtime deep-divessenior
- GC in production: observability, security, edge cases, and fleet governancesenior
- N+1: one logical operation, many round-tripsjunior
- Fix families: JOIN, IN, preload, and DataLoadermiddle
- Detecting N+1: query logs, APM traces, and CI gatesmiddle
- DataLoader: batching across resolver treesmiddle
- Cross-protocol N+1: HTTP fan-out and Redis MGETmiddle
- N+1 at scale: pool exhaustion, plan changes, and denormalisationsenior
- Batching: amortize fixed cost per operationjunior
- The batching window: size and wait timemiddle
- Batching in Kafka and Postgresmiddle
- io_uring and observability of batchingmiddle
- From Nagle to io_uring: evolution of batchingmiddle
- Backpressure, failure isolation, and batch security in productionsenior
- What a bundle actually costs: download, parse, compile, executejunior
- Core Web Vitals: LCP, INP, and CLSmiddle
- Code splitting: route-level, component-level, vendor splittingmiddle
- Tree shaking and compression: removing what you don''''t usemiddle
- Third-party scripts: the silent budget killermiddle
- CI enforcement and RUM: making budgets stickmiddle
- V8 JIT pipeline, HTTP priorities, and bundle securitysenior
- The performance loop: discipline, not a projectjunior
- Classify and fix: matching bottleneck families to remediesmiddle
- Observability stack and CI gates: catching regressions before they shipmiddle
- Incident to enforcement: SLO burn to verified fix in 35 minutesmiddle
- Culture, economics, and org-scale performancesenior
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.