PDO Prepared Statements and PostgreSQL Plan Caching: The Performance Win Your PHP Application Is Missing
Your prepared statements are being destroyed before they finish warming up. I regret to say this has been happening for some time.
Good evening. I have some rather uncomfortable news about your PDO configuration.
There is an optimization built into PostgreSQL that most PHP applications will never benefit from. It is not obscure. It is not experimental. It has been stable since PostgreSQL 9.2. It can reduce query planning overhead to near zero for your most frequently executed queries, saving hundreds of milliseconds of CPU time per second under production load.
The optimization is called plan caching for prepared statements. After a prepared statement has been executed five times on the same connection, PostgreSQL evaluates whether a generic plan — one that ignores specific parameter values and relies on table statistics instead — would perform comparably to the custom plans it has been generating. If so, it caches the generic plan and eliminates planning overhead entirely for all subsequent executions.
The problem: PHP-FPM creates a new database connection for every request and destroys it when the request completes. A prepared statement on a connection that lives for 45 milliseconds will execute two or three times at most. PostgreSQL's plan cache requires five executions to activate. The math is not complicated. Your prepared statements are being torn down four executions short of the performance threshold, ten thousand times per minute, across every FPM worker in your fleet.
This is not a bug. It is a structural incompatibility between PHP's execution model and PostgreSQL's plan caching strategy. And there are exactly four ways to address it, each with trade-offs worth understanding clearly.
I should note, before we proceed, that this is not an article about persuading you to switch languages. PHP-FPM's request lifecycle is a feature, not a defect. It provides clean process isolation, predictable memory behaviour, and protection against memory leaks that would cripple long-running processes. The request-and-reset model is why PHP applications survive in production environments that would reduce other runtimes to smouldering heaps. The plan cache limitation is a trade-off inherent to that model, not an indictment of it.
What we shall do instead is understand the trade-off precisely, measure its impact in your specific environment, and then choose the most appropriate remedy from the available options. If you will permit me, I shall be quite thorough.
Emulated vs native prepares: the first fork in the road
Before we discuss plan caching, we need to establish which kind of prepared statement your application is actually using. Many PHP developers are surprised to learn that PDO's prepare() method does not, by default, create a server-side prepared statement at all.
<?php
// PDO's pgsql driver uses NATIVE prepared statements by default.
// Your queries go through PostgreSQL's extended query protocol —
// PARSE, BIND, EXECUTE — not string interpolation.
$pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'pass');
// Check the default:
var_dump($pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES));
// bool(false) — native prepares are ON by default for pgsql
// (Note: MySQL's PDO driver defaults to true. PostgreSQL's does not.)
// Native prepare (the default): PostgreSQL handles preparation
$stmt = $pdo->prepare('SELECT * FROM orders WHERE user_id = :uid');
$stmt->execute([':uid' => 42]);
// What PostgreSQL receives (extended query protocol):
// PARSE: SELECT * FROM orders WHERE user_id = $1
// BIND: $1 = 42
// EXECUTE
// PostgreSQL caches the parse tree and (eventually) the plan.
// This sounds like it should help performance. It doesn't — and
// the reason has nothing to do with PDO. It has everything to do
// with PHP-FPM's connection lifecycle. Read on.
// Emulated prepare: PHP interpolates, sends plain SQL
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
$stmt = $pdo->prepare('SELECT * FROM orders WHERE user_id = :uid');
$stmt->execute([':uid' => 42]);
// What PostgreSQL actually receives:
// SELECT * FROM orders WHERE user_id = '42'
// No PREPARE. No EXECUTE. Just a plain query string.
// Some teams enable this for PgBouncer compatibility in transaction mode. When PDO::ATTR_EMULATE_PREPARES is true, PDO performs parameter interpolation in PHP. The bound values are escaped and inserted into the SQL string on the client side, and the resulting query is sent to PostgreSQL as a plain text query. PostgreSQL has no idea a prepared statement was involved. It receives SELECT * FROM orders WHERE user_id = '42' and parses it fresh. Note: the pgsql driver defaults to native prepares (false), but some teams enable emulation for PgBouncer compatibility or legacy reasons.
This is not inherently wrong. Emulated prepares provide SQL injection protection at the driver level (PDO handles escaping), they work universally across databases, and they avoid the overhead of the extended query protocol. For many applications, emulated prepares are perfectly adequate.
But they make plan caching structurally impossible. Without a server-side PREPARE command, there is no prepared statement for PostgreSQL to cache a plan against. Every query is a new query, even if the SQL text is identical to one executed a millisecond earlier.
To enable server-side prepared statements — the kind that can eventually benefit from plan caching — you must set PDO::ATTR_EMULATE_PREPARES to false. This switches PDO to the extended query protocol, sending PARSE, BIND, and EXECUTE messages separately. PostgreSQL receives a named prepared statement and can track its execution count.
Which brings us to the next problem.
A brief digression on what the wire actually carries
If you are the sort of person who prefers to understand what is happening at the protocol level — and I suspect you are, given that you are reading an article of this specificity — the distinction between emulated and native prepares is most clearly understood by examining what PostgreSQL actually receives over the wire.
-- What actually goes over the wire with native prepared statements.
--
-- The PostgreSQL wire protocol (v3) has two query paths:
--
-- SIMPLE QUERY PROTOCOL (emulated prepares):
-- Client sends: Query("SELECT * FROM orders WHERE user_id = '42'")
-- Server sends: RowDescription, DataRow..., CommandComplete, ReadyForQuery
-- One round trip. One message. The SQL string is the message.
--
-- EXTENDED QUERY PROTOCOL (native prepares):
-- Client sends: Parse("stmt_1", "SELECT * FROM orders WHERE user_id = $1", [int4])
-- Server sends: ParseComplete
-- Client sends: Bind("stmt_1", [42])
-- Server sends: BindComplete
-- Client sends: Describe("stmt_1")
-- Server sends: RowDescription
-- Client sends: Execute("stmt_1", 0)
-- Server sends: DataRow..., CommandComplete
-- Client sends: Sync
-- Server sends: ReadyForQuery
--
-- More messages, but the Parse step only happens ONCE per statement.
-- Subsequent executions skip Parse and go straight to Bind + Execute.
--
-- PDO batches Parse+Bind+Describe+Execute+Sync into a single network
-- round trip using pipelining, so the latency difference is negligible.
-- The CPU difference is what matters: Parse includes query parsing and
-- (on first execution) plan generation. Skipping Parse skips both. The simple query protocol is exactly what it sounds like. One message, one SQL string, one response. PostgreSQL parses the SQL, plans the query, executes it, and returns the results. Every time. There is no mechanism for the server to recognize that it has seen this query before, because from the protocol's perspective, it has not. Each message is independent. Each query is new.
The extended query protocol separates the lifecycle into discrete steps. PARSE submits the SQL text with placeholders and creates a named prepared statement on the server. BIND attaches parameter values to that statement. EXECUTE runs it. The critical insight: once a statement has been parsed, subsequent executions need only BIND and EXECUTE. The PARSE step — which includes syntactic analysis, semantic analysis, and rewrite-rule application — is performed once.
PDO pipelines these messages intelligently. A single prepare() followed by execute() sends PARSE, BIND, DESCRIBE, EXECUTE, and SYNC in one network round trip. The latency overhead compared to the simple query protocol is negligible. The CPU overhead on the PostgreSQL side, however, is meaningfully reduced on subsequent executions — if the prepared statement survives long enough to be reused.
In PHP-FPM, it does not.
The FPM lifecycle: why server-side prepares still do not help
Enabling native prepared statements is necessary but not sufficient. The connection lifecycle is the bottleneck, and PHP-FPM's architecture makes it a tight one.
# The PHP-FPM request lifecycle — and why it kills plan caching.
#
# Traditional server (Python/Node/Java/Ruby):
# App starts -> opens DB connection -> handles 100,000 requests -> connection lives for hours
# Prepared statements accumulate. Plans are cached. Performance improves over time.
#
# PHP-FPM:
# Request arrives -> FPM worker boots -> opens DB connection
# -> executes 5-20 queries -> request ends -> connection closed
# -> next request -> new connection -> all prepared statements gone
#
# Timeline of a PHP-FPM worker handling requests:
#
# Request 1 (45ms):
# connect() ─── PREPARE stmt_1 ─── EXECUTE stmt_1 ─── EXECUTE stmt_1 ─── disconnect()
# │ │
# │ plan cache: 2 executions │
# │ generic plan threshold: 5 │
# │ status: still using custom plans │
# └──────── connection destroyed, plan cache lost ───────────┘
#
# Request 2 (45ms):
# connect() ─── PREPARE stmt_1 ─── EXECUTE stmt_1 ─── EXECUTE stmt_1 ─── disconnect()
# │ │
# │ same query, brand new connection │
# │ plan cache starts over from zero │
# │ never reaches generic plan threshold │
# └──────── connection destroyed, plan cache lost again ─────┘
#
# The query is executed 10,000 times per minute across 50 FPM workers.
# PostgreSQL parses and plans it 10,000 times per minute.
# It never once benefits from plan caching. In a long-lived application server — a Python process running Gunicorn, a Node.js server, a Java application — database connections persist for hours or days. A prepared statement created at startup accumulates thousands of executions. PostgreSQL evaluates the generic plan option after five executions, switches to it, and from that point forward, planning overhead for that query drops to essentially zero.
In PHP-FPM, the connection lives for the duration of one HTTP request. A typical request executes 5-20 queries, but each unique query pattern typically appears only once or twice within a single request. The connection is destroyed at request end. The next request creates a new connection, and every prepared statement — along with its execution counter — starts over from zero.
The numbers tell the story. A query executed 10,000 times per minute across 50 FPM workers is parsed and planned 10,000 times per minute. In a long-lived server with a persistent connection, that same query would be parsed and planned exactly once (the initial PREPARE), reach the generic plan threshold after five executions, and incur zero planning overhead for the remaining 9,995 executions per minute.
At 0.5ms of planning overhead per execution, that is 5 seconds of CPU time per minute, per query. Across 20 hot query patterns in a typical application, that is 100 seconds of CPU time per minute spent re-planning queries whose plans have not changed.
I should be honest about the magnitude of this problem, because overstating it would not serve you. For simple single-table lookups on a primary key, planning overhead is modest — perhaps 0.2ms. The plan cache savings per query are correspondingly modest. Where the overhead becomes substantial is in queries involving joins, subqueries, or complex WHERE clauses, where the planner must evaluate multiple join strategies, access paths, and sort orders. A three-table join with a range predicate and an ORDER BY can easily consume 0.8-1.5ms in planning. If that query runs 2,000 times per minute, you are spending 1.6-3.0 seconds of CPU per minute planning it. Across a dozen such queries, the aggregate is non-trivial.
The key variable is not whether your queries are "slow." It is whether your planning-to-execution ratio is high. A query that takes 2ms total — 0.7ms planning, 1.3ms execution — has a planning ratio of 35%. Eliminating that planning overhead is equivalent to a 35% performance improvement for that query. Achieved by doing nothing more than allowing the plan cache to activate.
The connection tax: what PHP-FPM pays before a single query runs
The plan cache problem does not exist in isolation. It compounds with another cost that PHP-FPM's request lifecycle imposes: connection establishment overhead. Every request that opens a new database connection pays a tax before any query can execute.
-- Connection establishment overhead: the hidden cost PHP pays per request
--
-- Creating a new PostgreSQL connection is not free.
-- Here is what happens during connection setup:
--
-- 1. TCP handshake: 3 packets (SYN, SYN-ACK, ACK)
-- Localhost: ~0.05ms Remote (same region): ~0.5ms
--
-- 2. PostgreSQL startup: authentication + session setup
-- Password auth: ~1-3ms
-- SCRAM-SHA-256: ~3-8ms (cryptographic exchange)
-- SSL/TLS handshake (if enabled): ~2-5ms additional
--
-- 3. PostgreSQL backend process fork
-- The postmaster forks a new process for each connection.
-- Fork cost: ~1-5ms depending on shared_buffers size
-- (larger shared_buffers = more memory mappings = slower fork)
--
-- Total per-request connection overhead:
-- Localhost, password auth, no SSL: 2-8ms
-- Remote, SCRAM + SSL: 8-20ms
--
-- For a request that executes 10 queries averaging 2ms each (20ms total):
-- Connection overhead is 10-50% of total database time.
-- This overhead exists independently of the plan cache problem.
-- A connection pooler eliminates it regardless of prepared statement strategy.
--
-- PHP-FPM without a pooler pays this cost on EVERY request.
-- PHP-FPM with PgBouncer pays it once (pooler <-> PostgreSQL).
-- PHP-FPM with Gold Lapel pays it once AND gets plan caching. The connection tax is worth understanding because it affects your choice of remedy. A connection pooler — PgBouncer, pgcat, or a proxy like Gold Lapel — eliminates connection establishment overhead regardless of its plan cache behaviour. PHP-FPM connects to the pooler, which maintains pre-established connections to PostgreSQL. The TCP handshake, authentication exchange, and backend process fork have already happened.
If you are not using any connection pooler today, the connection overhead alone may justify adding one. The plan cache improvement is additive — a bonus on top of the connection overhead savings.
If you are already using PgBouncer, the connection overhead is solved. The remaining question is whether the plan cache limitation matters enough in your workload to warrant a different approach. Which brings us to measurement.
Measuring the damage: how to quantify your planning overhead
I find that the most productive conversations about database performance begin with numbers, not opinions. Before committing to any solution, I would suggest measuring the actual planning overhead in your application. PostgreSQL provides the tools. You need only ask.
<?php
// Measuring the actual planning overhead in your application.
// This is not theoretical. You can measure it directly.
$pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'pass', [
PDO::ATTR_EMULATE_PREPARES => false,
]);
// Method 1: EXPLAIN ANALYZE with timing
$stmt = $pdo->query("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT o.id, o.total, o.created_at, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.user_id = 42
AND o.status = 'completed'
ORDER BY o.created_at DESC
LIMIT 20");
$plan = json_decode($stmt->fetchColumn(), true);
// Look at these two fields in the output:
// "Planning Time": 0.847 <-- this is what you pay per execution
// "Execution Time": 1.203 <-- this is the actual work
//
// Planning Time: 0.847ms
// If this query runs 500 times/minute, that's 423ms/min of pure planning.
// With a cached generic plan, Planning Time drops to ~0.02ms.
// Savings: 412ms/min for this ONE query.
// Method 2: pg_stat_statements (requires extension)
// SELECT query,
// calls,
// mean_exec_time, -- execution time per call (ms)
// mean_plan_time, -- planning time per call (ms)
// (mean_plan_time / (mean_plan_time + mean_exec_time)) * 100
// AS plan_pct -- what % of total time is planning?
// FROM pg_stat_statements
// WHERE calls > 100
// ORDER BY plan_pct DESC
// LIMIT 20;
//
// If plan_pct > 30%, planning dominates. These are your highest-impact
// candidates for plan cache optimization.
//
// Typical findings in PHP applications:
// Simple lookups (WHERE id = $1): plan_pct 40-60%
// JOIN queries (2-3 tables): plan_pct 20-40%
// Complex analytics (5+ joins, aggs): plan_pct 5-15%
//
// The simple queries benefit MOST from plan caching,
// and they are also the most frequently executed.
// This is not a coincidence — it is how databases work. The pg_stat_statements extension is, in my professional estimation, the single most valuable diagnostic tool available to any PostgreSQL administrator. It tracks every distinct query pattern, its call count, and — critically — its mean planning time separate from its mean execution time. This separation is what allows you to identify queries where the planner is consuming a disproportionate share of total query time.
The pattern you are looking for is a high plan_pct combined with a high calls count. A query with 60% planning overhead that runs 3 times per day is not worth optimizing. A query with 40% planning overhead that runs 500,000 times per day is leaving substantial CPU on the table.
-- pg_stat_statements: finding your highest-impact queries for plan caching
--
-- This extension ships with PostgreSQL but must be enabled:
-- shared_preload_libraries = 'pg_stat_statements' -- in postgresql.conf
-- CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Find queries where planning dominates total time:
SELECT
left(query, 80) AS query_preview,
calls,
round(mean_plan_time::numeric, 3) AS avg_plan_ms,
round(mean_exec_time::numeric, 3) AS avg_exec_ms,
round(
(mean_plan_time / NULLIF(mean_plan_time + mean_exec_time, 0)) * 100,
1
) AS plan_pct,
round(total_plan_time::numeric, 0) AS total_plan_ms,
round(total_exec_time::numeric, 0) AS total_exec_ms
FROM pg_stat_statements
WHERE calls > 100
AND mean_plan_time > 0
ORDER BY total_plan_time DESC
LIMIT 20;
-- Example output:
--
-- query_preview | calls | avg_plan | avg_exec | plan_pct | total_plan | total_exec
-- -------------------------------------------------+--------+----------+----------+----------+------------+-----------
-- SELECT o.* FROM orders o WHERE o.user_id = $1... | 847293 | 0.412 | 0.891 | 31.6% | 349076 | 754975
-- SELECT u.* FROM users u WHERE u.id = $1 | 623847 | 0.287 | 0.143 | 66.7% | 179044 | 89211
-- INSERT INTO audit_log (user_id, action, ... | 412093 | 0.523 | 0.312 | 62.6% | 215524 | 128573
--
-- That second row is telling: a simple primary key lookup where
-- planning takes TWICE as long as execution. 66.7% of the total
-- time for this query is spent deciding HOW to run it.
-- With a cached generic plan, that 0.287ms drops to ~0.02ms.
-- Across 623,847 calls: 166 seconds of CPU time saved. In my experience with PHP applications — and I have attended to rather a lot of them — the distribution is remarkably consistent. The queries that benefit most from plan caching are exactly the queries you might dismiss as "already fast": simple primary key lookups, single-table selects with an equality predicate, and two-table joins on indexed foreign keys. These queries execute in 0.5-3ms total, with planning consuming 30-60% of that time. They also tend to be the most frequently executed queries in the application, because they back the endpoints that handle the majority of traffic.
Complex queries — five-table joins with aggregations, window functions, CTEs — have lower planning ratios because execution dominates. But they also run less frequently. The aggregate planning overhead of your twenty hottest simple queries typically exceeds the aggregate planning overhead of your ten most complex queries by a factor of five or more.
The simple queries are where the money is. And they are the queries most likely to benefit from generic plans, because their optimal plans rarely vary with parameter values.
Persistent connections: the obvious fix that will ruin your weekend
PHP has a mechanism for keeping database connections alive across requests. You have probably considered it already. I would advise caution.
<?php
// PHP's persistent connections: the obvious fix that isn't.
// Regular connection — destroyed at end of request:
$pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'pass');
// Persistent connection — survives across requests within the same FPM worker:
$pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'pass', [
PDO::ATTR_PERSISTENT => true,
]);
// Sounds perfect. The connection stays open. Prepared statements
// should accumulate. Plan caching should kick in after 5 executions.
//
// The problems:
//
// 1. Transaction state leaks between requests.
// If request A starts a transaction and crashes before COMMIT,
// request B inherits the open transaction. Silent data corruption.
//
// 2. Session state leaks between requests.
// SET search_path, SET timezone, SET role — all carry over.
// Request B runs with request A's privileges. Security nightmare.
//
// 3. Prepared statement name collisions.
// Two different code paths in your app both PREPARE "stmt_1"
// with different SQL. Second one gets DuplicatePreparedStatementError.
// Unless you use DEALLOCATE ALL at request start — which defeats the purpose.
//
// 4. Connection limits.
// Each FPM worker holds its own persistent connection.
// 50 FPM workers = 50 connections.
// Scale to 10 servers = 500 connections.
// PostgreSQL default max_connections: 100.
//
// Most teams try persistent connections, hit one of these problems
// within a week, and switch back to per-request connections. Persistent connections (PDO::ATTR_PERSISTENT = true) reuse the same connection handle across requests within a single FPM worker process. The connection survives request boundaries, prepared statements accumulate, and plan caching can theoretically activate.
In practice, persistent connections introduce four categories of bugs that are difficult to detect and expensive to diagnose.
Transaction state leakage. If a request begins a transaction and fails before committing — due to an exception, a timeout, or an OOM kill — the next request inherits the open transaction. Data written by the second request may be silently rolled back when the connection is eventually recycled, or committed alongside data from the failed request. This class of bug produces intermittent data loss that appears only under error conditions, which is to say, precisely when you can least afford it.
Session state leakage. SET commands persist across requests. If request A sets search_path to a tenant-specific schema, request B inherits that schema and may read or write data belonging to the wrong tenant. The security implications need no elaboration.
Connection exhaustion. Each FPM worker holds one persistent connection. With 50 workers per server and 10 servers, that is 500 connections to PostgreSQL. The default max_connections is 100. You will hit this limit quickly, and increasing it has diminishing returns — PostgreSQL's per-connection memory overhead means 500 connections consume significantly more RAM than 50 connections serving the same load through a pooler.
Name collisions. Different code paths in your application may prepare statements with the same name but different SQL. On a fresh connection, this works because each request gets its own namespace. On a persistent connection, the second PREPARE fails with DuplicatePreparedStatementError. The common workaround — DEALLOCATE ALL at request start — destroys all prepared statements, eliminating the plan cache benefit you were trying to achieve.
Most teams that try persistent connections retreat within a week. The plan cache benefit is real, but the operational risk is not worth it in most PHP environments.
The DEALLOCATE ALL trap: making persistent connections worse than useless
I bring this up because I have seen this pattern deployed in production more times than I would like to admit. It is the natural endpoint of trying to make persistent connections safe, and it deserves explicit examination because it looks like a solution while being precisely the opposite.
<?php
// The DEALLOCATE ALL workaround for persistent connections.
// This is what teams try when they hit name collisions.
$pdo = new PDO('pgsql:host=localhost;dbname=myapp', 'user', 'pass', [
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// Clean slate at the start of each request:
$pdo->exec('DEALLOCATE ALL'); // destroy all prepared statements
$pdo->exec('RESET ALL'); // reset all session variables
$pdo->exec('DISCARD SEQUENCES'); // reset sequence state
$pdo->exec('DISCARD TEMP'); // drop temporary tables
// Or the nuclear option:
$pdo->exec('DISCARD ALL'); // resets EVERYTHING (invalidates prepared stmts too)
// The irony: DEALLOCATE ALL destroys the very prepared statements
// whose accumulated execution counts were the entire point of using
// persistent connections in the first place.
//
// You are now paying the cost of persistent connections (state leakage risk,
// connection exhaustion) without receiving the benefit (plan caching).
//
// This is worse than per-request connections. You have the downsides of both
// approaches and the benefits of neither.
//
// RESET ALL is necessary for safety (prevents session state leakage).
// DEALLOCATE ALL is necessary for correctness (prevents name collisions).
// Together, they reduce persistent connections to expensive per-request
// connections with extra steps. The reasoning is intuitive: persistent connections have state leakage problems, so reset the state at the start of each request. DEALLOCATE ALL clears prepared statements. RESET ALL clears session variables. DISCARD TEMP drops temporary tables. Clean slate.
The problem is that DEALLOCATE ALL destroys the execution counters that drive plan caching. You have eliminated the hazards of persistent connections by also eliminating the only benefit of persistent connections. What remains is a per-request connection with extra overhead — you are now paying for the DEALLOCATE, RESET, and DISCARD commands on every request, plus the ongoing connection memory overhead, in exchange for precisely nothing.
This is worse than the default per-request connection model. You have the memory cost of persistent connections, the complexity of state management, and the performance characteristics of fresh connections. A waiter who polishes the silver and then throws it in the bin has misunderstood the assignment.
If you find this pattern in your codebase, remove it. Switch to per-request connections or use a proper connection pooler. Either is strictly superior.
PostgreSQL's plan cache lifecycle: what the five-execution threshold actually does
To understand why the connection lifecycle matters so much, it helps to see exactly what PostgreSQL does with a prepared statement over its lifetime.
-- PostgreSQL plan cache lifecycle: the 5-execution threshold
--
-- When you PREPARE a statement, PostgreSQL does NOT immediately cache a plan.
-- Here is what actually happens, execution by execution:
--
-- Execution 1-5: CUSTOM PLANS
-- PostgreSQL generates a fresh plan using the actual parameter values.
-- Each plan is optimized for the specific values provided.
-- Cost: full planning overhead each time (~0.2-1.0ms per plan)
--
-- After execution 5: DECISION POINT
-- PostgreSQL generates a GENERIC plan (ignoring parameter values,
-- using table statistics instead).
-- It compares the generic plan's estimated cost against the average
-- cost of the 5 custom plans.
--
-- If generic plan cost <= 1.1 * average custom plan cost:
-- -> Switch to generic plan permanently for this statement
-- -> Planning cost drops to near zero
-- -> This is the performance win
--
-- If generic plan cost > 1.1 * average custom plan cost:
-- -> Keep using custom plans
-- -> Full planning overhead continues
-- -> But plans are more accurate for skewed data
--
-- The critical insight for PHP:
-- With per-request connections, you NEVER reach execution 5.
-- You get custom plans every time — the expensive kind.
-- The plan cache optimization literally cannot activate.
-- Monitor plan cache behavior:
SELECT name,
calls,
generic_plans,
custom_plans
FROM pg_prepared_statements;
-- In a long-lived connection after 1000 executions:
-- name: get_user_orders calls: 1000 generic_plans: 995 custom_plans: 5
--
-- In a PHP-FPM connection after 3 executions:
-- name: get_user_orders calls: 3 generic_plans: 0 custom_plans: 3
-- (connection destroyed before reaching execution 5) The plan cache is not a simple key-value store. It is an adaptive system that balances planning cost against plan quality.
For the first five executions, PostgreSQL generates a custom plan — one that uses the actual parameter values to estimate selectivity and choose the optimal access path. If your WHERE status = $1 is called with 'pending' (50 rows), PostgreSQL chooses an index scan. If called with 'completed' (9 million rows), it chooses a sequential scan. Custom plans are accurate but expensive: each one requires the full planning cycle.
After the fifth execution, PostgreSQL creates a generic plan that substitutes table-level statistics for actual parameter values. It compares this generic plan's estimated cost against the average cost of the five custom plans. If the generic plan is within 10% of the custom plan cost, PostgreSQL adopts it. From that point forward, the query executes with zero planning overhead.
This is where the magic lives — and where PHP-FPM cannot reach it. A connection that lives for 45 milliseconds and executes a query twice will never trigger the generic plan evaluation. The planning overhead that should have been eliminated after five executions is instead paid ten thousand times per minute.
The 10% threshold is a carefully chosen heuristic, and it bears a moment of appreciation. PostgreSQL does not blindly adopt generic plans. It verifies that the generic plan is not meaningfully worse than parameter-specific plans. This protects against the skewed-data scenario: if your status column has values that produce wildly different plan shapes, PostgreSQL will correctly decide that a generic plan is too coarse and will continue generating custom plans. The cost is ongoing planning overhead. The benefit is that every execution gets the right plan for its specific parameters.
This adaptive behaviour is why plan_cache_mode = force_generic_plan is dangerous, and why the five-execution warm-up period exists rather than caching immediately. PostgreSQL is not being conservative for the sake of it. It is being correct.
"PostgreSQL is not a database. It is an ecosystem that most teams use as a database."
— from You Don't Need Redis, Chapter 1: Good Evening. We Have a Problem.
The force_generic_plan temptation: a word of counsel
PostgreSQL 12 added plan_cache_mode as a session variable, and I know exactly what you are thinking. If the problem is that PHP connections never reach five executions, why not force generic plans immediately and skip the warm-up entirely?
-- PostgreSQL 12+ plan_cache_mode: forcing generic or custom plans
--
-- This is a session-level GUC (Grand Unified Configuration) variable.
-- It affects ALL prepared statements on the connection.
-- Default behavior: auto (the 5-execution threshold)
SET plan_cache_mode = 'auto';
-- Force generic plans immediately (skip the 5-execution warm-up):
SET plan_cache_mode = 'force_generic_plan';
PREPARE get_user_orders(int) AS
SELECT * FROM orders WHERE user_id = $1;
-- First execution: already uses generic plan. No warm-up needed.
EXECUTE get_user_orders(42);
-- Sounds like it solves the PHP problem, right? Set it at connection start,
-- skip the 5-execution threshold, generic plans immediately.
--
-- The catch: generic plans are not always GOOD plans.
--
-- Consider a table with highly skewed data:
-- status = 'pending' -> 47 rows (0.001%)
-- status = 'completed' -> 9,999,953 rows (99.999%)
--
-- Custom plan for status = 'pending': Index Scan (fast: 0.1ms)
-- Custom plan for status = 'completed': Seq Scan (correct for 10M rows)
-- Generic plan (uses average statistics): Seq Scan (always)
--
-- With force_generic_plan, the 'pending' query does a Seq Scan
-- over 10 million rows instead of an Index Scan over 47 rows.
-- That is not a performance improvement. That is a catastrophe.
--
-- force_generic_plan is safe ONLY when your parameter distributions
-- are roughly uniform. Most real-world data is not uniform.
-- Force custom plans (always re-plan with actual values):
SET plan_cache_mode = 'force_custom_plan';
-- Useful for debugging plan cache issues or when you know
-- your data is highly skewed and generic plans perform poorly. I have seen this deployed in production exactly twice. Both times, it was reverted within 48 hours.
The first deployment was at an e-commerce platform. Their orders table had a status column with five values, four of which contained fewer than 100 rows ('pending', 'processing', 'refunded', 'cancelled') and one of which contained 12 million rows ('completed'). With force_generic_plan, the generic plan for SELECT * FROM orders WHERE status = $1 was a sequential scan — correct for the 12-million-row value, catastrophic for the 47-row value. Their order processing pipeline, which queried status = 'pending' every 30 seconds, went from 0.3ms to 4,200ms per execution. They noticed when the processing queue backed up.
The second deployment was at a SaaS application with a multi-tenant database. Their queries parameterized tenant_id, and tenant sizes ranged from 50 rows to 3 million rows. Generic plans chose access paths based on average tenant size, which was wrong for both the smallest and largest tenants.
force_generic_plan is safe in one narrow circumstance: when every parameterized column in every query on the connection has a roughly uniform distribution. In practice, this is rare. Most real-world data is skewed, and the skew is exactly what makes custom plans valuable.
I mention this not to be discouraging, but because a waiter who omits the caveats is no waiter at all — merely a salesman with better posture.
Framework configurations: Laravel and Symfony with native prepares
Regardless of the plan caching limitation, enabling server-side prepared statements in your framework is still worth doing. Native prepares provide stronger SQL injection protection (parameter binding happens at the protocol level, not via string escaping) and eliminate certain edge cases around character encoding and numeric type coercion. The plan cache benefit may be limited, but the correctness benefit is real.
<?php
// Laravel: enabling server-side prepared statements
// config/database.php
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'options' => [
PDO::ATTR_EMULATE_PREPARES => false, // use real server-side prepares
],
],
// What this changes in practice:
//
// Before (emulated):
// Laravel -> PDO interpolates params -> sends: SELECT * FROM users WHERE id = '42'
// PostgreSQL: parse + plan + execute (every time)
//
// After (native):
// Laravel -> PDO sends PARSE: SELECT * FROM users WHERE id = $1
// -> PDO sends BIND: $1 = 42
// -> PDO sends EXECUTE
// PostgreSQL: parse + plan on first call, execute-only on subsequent calls
// ...within the same connection.
//
// The savings per query: 0.3-1.5ms of planning overhead eliminated.
// But ONLY if the connection lives long enough to reuse the prepared statement.
// With PHP-FPM's per-request lifecycle, the connection rarely does. In Laravel, the configuration is a single line in config/database.php. Set PDO::ATTR_EMULATE_PREPARES => false in the options array. Every Eloquent query, every Query Builder call, and every raw DB::select() will use the extended query protocol.
A note on Laravel's query log: when emulated prepares are enabled, Laravel's DB::getQueryLog() shows the interpolated SQL string with values embedded. After switching to native prepares, the log shows parameterized queries with $1, $2 placeholders, and the bindings array separately. This occasionally surprises developers who are used to copying queries from the log into psql for debugging. The queries are identical in function; they simply appear differently in diagnostic output.
<?php
// Symfony / Doctrine DBAL: server-side prepares with PostgreSQL
// config/packages/doctrine.yaml equivalent in PHP:
// doctrine:
// dbal:
// driver: pdo_pgsql
// options:
// !php/const PDO::ATTR_EMULATE_PREPARES: false
// Or in a Doctrine DBAL connection directly:
use Doctrine\DBAL\DriverManager;
$connection = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'dbname' => 'myapp',
'user' => 'appuser',
'password' => 'secret',
'driverOptions' => [
PDO::ATTR_EMULATE_PREPARES => false,
],
]);
// Doctrine's query builder then uses server-side prepares:
$qb = $connection->createQueryBuilder();
$result = $qb->select('o.id', 'o.total', 'o.created_at')
->from('orders', 'o')
->where('o.user_id = :userId')
->andWhere('o.status = :status')
->setParameter('userId', 42)
->setParameter('status', 'completed')
->executeQuery();
// Under the hood, PDO sends a real PREPARE to PostgreSQL.
// But the connection is destroyed when FPM finishes the request.
// The prepared statement never executes more than once or twice. In Symfony with Doctrine DBAL, the same setting is applied through driverOptions. Doctrine's DQL and Query Builder both benefit from the change.
A word of caution with both frameworks: some PostgreSQL-specific features behave differently under the extended query protocol. COPY commands, multi-statement queries separated by semicolons, and certain DO blocks may require adjustments. Test thoroughly after enabling native prepares, particularly if your application uses raw SQL alongside the ORM.
There is one additional framework-specific consideration that deserves mention. Both Laravel and Symfony use named placeholders (:userId, :status) in their query builders, which PDO translates to positional parameters ($1, $2) when using the pgsql driver with native prepares. This translation is handled transparently. However, if you have raw queries that already use positional parameters ($1, $2) — common in code that was written directly for PostgreSQL — ensure you are not mixing named and positional placeholders in the same query, as PDO's behaviour in that case is undefined and varies between driver versions.
PgBouncer 1.21: prepared statement support in transaction mode
PgBouncer 1.21, released in late 2023, added a feature that PHP developers had been requesting for years: prepared statement tracking in transaction mode.
# PgBouncer 1.21+ adds prepared statement support in transaction mode.
# This is the closest thing to a fix at the pooler level.
# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200 # new in 1.21 — track up to 200 per connection
max_client_conn = 1000
default_pool_size = 20
# With this configuration:
# 1. PHP-FPM connects to PgBouncer (port 6432)
# 2. PHP sends PREPARE via server-side prepared statements
# 3. PgBouncer remembers the statement text and name
# 4. When the backend connection is reassigned, PgBouncer
# re-prepares the statement transparently
# 5. PHP never knows the difference
#
# But: the plan cache problem remains.
# PgBouncer re-prepares statements on new backend connections,
# which resets the execution counter.
# You still rarely reach the 5-execution threshold for generic plans.
#
# PgBouncer solves the NAMING problem (statement not found errors).
# It does NOT solve the CACHING problem (plans rebuilt every time). With max_prepared_statements set, PgBouncer intercepts PARSE messages from clients, records the statement name and SQL text, and transparently re-prepares statements on whichever backend connection is assigned to the next transaction. From PHP's perspective, the prepared statement "just works" across requests, even though the backend connection has changed.
This solves half the problem. PgBouncer eliminates the InvalidSQLStatementNameError and DuplicatePreparedStatementError that previously made server-side prepares incompatible with transaction-mode pooling. PHP applications can safely enable ATTR_EMULATE_PREPARES = false behind PgBouncer 1.21+.
But PgBouncer does not solve the plan cache problem. When a statement is re-prepared on a new backend connection, PostgreSQL's execution counter starts over from zero on that backend. The five-execution threshold applies per backend connection, not globally. If PgBouncer rotates your requests across 20 backend connections, each backend sees one-twentieth of the executions. A query that runs 100 times per minute might hit any given backend 5 times per minute — barely reaching the generic plan threshold, if at all.
# PgBouncer backend rotation and its effect on plan cache accumulation
#
# Scenario: 20 backend connections, 100 queries/second for a given statement
#
# With round-robin-ish distribution:
# Each backend sees ~5 queries/second
# Time to reach 5 executions on ONE backend: ~1 second
#
# That sounds fine. But PgBouncer doesn't do round-robin.
# It assigns backends per TRANSACTION, and each transaction gets
# whichever backend is free. With bursty PHP traffic:
#
# Backend 1: 3 executions, then idle for 2 seconds
# Backend 2: 7 executions (reached threshold! generic plan active)
# Backend 3: 1 execution, then reassigned to different client
# Backend 4: 4 executions, then different queries arrive
#
# Worse: when PgBouncer needs to re-prepare a statement on a
# backend that hasn't seen it yet, the execution counter for THAT
# statement on THAT backend resets to zero.
#
# Re-prepare = new PREPARE on backend = counter starts at 0
#
# In practice, with 20 backends and variable traffic patterns,
# most backends hover around 2-4 executions per statement before
# the connection is reassigned or goes idle. Generic plan activation
# is sporadic at best.
#
# PgBouncer 1.21+ reliably delivers:
# - No more "prepared statement does not exist" errors
# - No more "duplicate prepared statement" errors
# - Parse overhead reduction (re-prepare is cheaper than first prepare)
#
# PgBouncer 1.21+ does NOT reliably deliver:
# - Generic plan activation across the majority of backends
# - Elimination of planning overhead for hot queries The math is less favourable than it appears at first glance. PgBouncer's connection assignment is not round-robin — it assigns whichever backend is currently idle. Under bursty traffic patterns (which PHP-FPM inherently produces, since each request is a short burst of queries followed by silence), some backends accumulate more executions than others. A few backends might reach the generic plan threshold. Most will hover in the 2-4 execution range, perpetually short of the threshold.
There is also a subtlety around re-preparation. When PgBouncer re-prepares a statement on a backend that has not seen it before, PostgreSQL treats this as a new PREPARE — the execution counter starts at zero. This is correct behaviour from PostgreSQL's perspective. PgBouncer is creating a new prepared statement; the fact that the SQL text is identical to one previously prepared on a different backend is not information PostgreSQL has or uses.
PgBouncer 1.21 is a meaningful improvement. It makes native prepared statements safe to use in PHP. But it does not deliver the full plan cache performance benefit. That requires something PgBouncer's architecture cannot provide: long-lived connections where prepared statements accumulate thousands of executions on the same backend.
An honest counterpoint: when plan caching does not matter
I have been making a sustained case for the importance of plan cache activation, and I believe the case is sound. But a waiter who overstates his case is no waiter at all, and there are workloads where the plan cache optimization is genuinely not worth pursuing.
If your queries are complex and parameter-sensitive, plan caching may not activate even on long-lived connections. Queries that join five or more tables with range predicates, LIKE patterns, or array containment operators often produce parameter-dependent plans that are genuinely different for different input values. PostgreSQL's adaptive system will correctly reject the generic plan for these queries, and you will pay planning overhead regardless of connection lifecycle. The plan cache cannot help queries whose plans should vary with their parameters.
If your application is I/O-bound rather than CPU-bound, planning overhead is noise. An application where the average query spends 50ms waiting on disk I/O and 0.5ms in planning has a planning ratio of 1%. Eliminating that 0.5ms — even across thousands of queries — will not produce a visible improvement in throughput or latency. Your bottleneck is elsewhere, and attending to the plan cache is attending to the wrong room of the household.
If your total query volume is low — say, fewer than 100 queries per second across all patterns — the aggregate planning overhead is measured in single-digit milliseconds per second. There is no meaningful CPU capacity to reclaim. Spend your optimisation budget elsewhere.
If you are already using pg_stat_statements and your highest-plan-ratio queries have total_plan_time values below a few hundred milliseconds over your measurement window, the plan cache optimisation is not the lever to pull. This is the correct way to evaluate the trade-off: with data, not with assumptions.
The plan cache optimisation matters most for applications with high query volumes, moderate-complexity queries (1-3 joins), and CPU-constrained database servers. If that describes your application, the savings are real. If it does not, your time is better spent on indexing, query structure, or connection pooling.
The complete comparison: every approach and its trade-offs
I have mapped every viable configuration. Here is the honest accounting.
| Configuration | Server-side prepare | Plan cache | Injection safety | Parse overhead | Best for |
|---|---|---|---|---|---|
| Emulated prepares | No | Never | Driver-level | Every query | PgBouncer transaction mode (legacy) |
| Native prepares, per-request conn (default) | Yes | Never (< 5 executions) | Protocol-level | Every query (PREPARE cost) | Most PHP apps today |
| Native prepares, persistent conn | Yes | Possible (risky) | Protocol-level | Once per statement | Controlled environments only |
| PgBouncer 1.21+ transaction mode | Yes | Rare (conn reassignment) | Protocol-level | Per backend switch | High connection count |
| Gold Lapel proxy | Yes (proxy-managed) | Yes (long-lived upstream) | Protocol-level | Once per hot query | Full plan cache benefits |
The pattern is clear. Every approach that works within PHP-FPM's per-request lifecycle sacrifices the plan cache. The only configurations that deliver full plan cache benefits require long-lived connections — either persistent connections (with their operational hazards) or a proxy that maintains its own persistent upstream connections.
I want to draw your attention to the "Best for" column, because it reveals something about the nature of trade-offs in this space. There is no single configuration that is optimal for all applications. Emulated prepares are fine for legacy applications that cannot risk behavioural changes. Native prepares with per-request connections are a sensible default for security-conscious applications that do not need plan caching. PgBouncer 1.21+ is the right choice when connection count is your primary constraint. Each approach has its correct deployment context.
What none of them achieves, individually, is the combination of safe connection handling, transparent prepared statement management, and full plan cache activation. That particular combination requires a different architectural approach.
The decision tree: which configuration is right for your application
I have condensed the analysis into a decision tree. Follow the questions from the top.
# Decision tree: PDO prepared statements with PostgreSQL
#
# Q: Are you using PDO::ATTR_EMULATE_PREPARES = true?
# │
# ├── Yes: Your queries are never server-side prepared.
# │ PostgreSQL parses and plans every query from scratch.
# │ You get no plan cache benefit whatsoever.
# │ → Set ATTR_EMULATE_PREPARES = false as a first step.
# │
# └── No (native prepares enabled):
# │
# Q: Are your PHP connections per-request (standard FPM)?
# │
# ├── Yes: Prepared statements are created and destroyed each request.
# │ Plan cache never reaches the 5-execution generic plan threshold.
# │ You pay PREPARE overhead without getting plan cache benefit.
# │ │
# │ Q: Are you using a connection pooler?
# │ │
# │ ├── No pooler: Each FPM worker connects directly to PostgreSQL.
# │ │ Connection created/destroyed per request.
# │ │ → Consider PgBouncer or Gold Lapel.
# │ │
# │ ├── PgBouncer < 1.21: Prepared statements will ERROR.
# │ │ PgBouncer cannot track PARSE/BIND/EXECUTE in transaction mode.
# │ │ → Set ATTR_EMULATE_PREPARES = true, OR upgrade PgBouncer.
# │ │
# │ ├── PgBouncer >= 1.21: Prepared statements work, but plan cache
# │ │ still resets on backend reassignment.
# │ │ → Partial benefit only. Errors are gone; plans still rebuilt.
# │ │
# │ └── Gold Lapel: Long-lived upstream connections accumulate
# │ plan cache across all PHP requests. Full benefit.
# │ → Change connection host/port. No other code changes.
# │
# └── No (persistent connections):
# Prepared statements persist across requests within one FPM worker.
# Plan cache CAN reach generic plan threshold.
# But: transaction leaks, session state leaks, connection exhaustion.
# → Generally not recommended for production. A few clarifications on the branches.
If you are using emulated prepares (not the default for pgsql, but sometimes enabled for PgBouncer compatibility), consider switching to native prepares. This is a one-line configuration change in Laravel or Symfony. You gain protocol-level parameter binding, which is a security improvement even without plan cache benefits. Test your application afterward — most code works identically, but edge cases exist with multi-statement queries and COPY commands.
If you are on PgBouncer older than 1.21, you have a choice: keep emulated prepares (safe, no plan cache), or upgrade PgBouncer. The upgrade is straightforward if you manage your own infrastructure. If your PgBouncer is managed by a hosting provider, check their version — many providers were still on 1.18 or 1.19 as of early 2026.
If you are evaluating your stack and prepared statement plan caching is one of several performance concerns, the proxy approach addresses the root cause rather than individual symptoms. Connection lifecycle management, connection pooling, and query optimization all benefit from a layer that maintains long-lived upstream connections independent of the application's request lifecycle.
If you are running a read-heavy workload with read replicas, the decision tree applies to each connection independently. Your primary server's write queries may benefit less from plan caching (INSERT and UPDATE plans are simpler and plan faster), while your replica's read queries — particularly those involving joins and aggregations — may benefit substantially. Evaluate planning overhead on each server separately.
Where Gold Lapel fits: solving the lifecycle mismatch at the proxy layer
I shall be brief, as you have had quite a lot of information to process. But the connection lifecycle problem we have been discussing has a structural solution that is worth mentioning.
<?php
// Gold Lapel: composer require goldlapel/goldlapel, keep your existing code.
$pdo = new PDO(
'pgsql:host=localhost;port=5433;dbname=myapp', // GL's port
'user',
'pass',
[PDO::ATTR_EMULATE_PREPARES => false]
);
// Your Laravel / Symfony / custom PHP code does not change.
// The same queries flow through Gold Lapel's proxy.
//
// What happens differently:
//
// 1. PHP-FPM opens a connection to Gold Lapel (port 5433)
// 2. PHP sends queries (prepared or plain — GL handles both)
// 3. Request ends, PHP closes connection to GL
// 4. GL's upstream connection to PostgreSQL stays OPEN
//
// That upstream connection is long-lived. Hours. Days.
// Gold Lapel's LRU cache (1,024 slots per connection) promotes your hottest queries
// to server-side prepared statements on the persistent connection.
//
// The plan cache lifecycle that PHP could never complete:
//
// Request 1 (via GL): GL sees SELECT ... WHERE user_id = $1
// Execution count on upstream conn: 1
// Request 2 (via GL): Same query. Count: 2
// Request 3 (via GL): Count: 3
// Request 4 (via GL): Count: 4
// Request 5 (via GL): Count: 5 → PostgreSQL evaluates generic plan
// Request 6+ (via GL): Generic plan active. Planning cost: ~0ms
//
// 10,000 queries/minute that were each costing 0.5ms of planning overhead
// now cost 0ms after the first 5 executions. That is 83 seconds of CPU
// time saved per minute. Across your entire query surface, automatically. Gold Lapel is a PostgreSQL proxy that sits between your PHP application and your database. PHP-FPM connects to Gold Lapel on port 5433. Gold Lapel maintains its own pool of long-lived upstream connections to PostgreSQL — connections that persist for hours or days, independent of PHP's request lifecycle.
The prepared statement promotion happens at the proxy layer. Gold Lapel maintains an LRU cache of 1,024 prepared statement slots per connection. As it observes query traffic, it identifies frequently executed query patterns and promotes them to server-side prepared statements on the upstream connection. The upstream connection lives long enough for PostgreSQL's plan cache to activate — five executions happen in seconds, not across requests that will never share a connection.
The result: your PHP application's hottest queries benefit from generic plan caching without any code changes. No PDO::ATTR_EMULATE_PREPARES configuration required (though enabling native prepares is still recommended for the security benefits). No persistent connection hazards. No PgBouncer version requirements. Change the connection host and port in your database configuration. That is the entire integration.
10,000 queries per minute that were each paying 0.5ms of planning overhead now pay 0ms after the initial warm-up. That is 83 seconds of CPU time saved per minute, per hot query pattern. Across 20 common query patterns in a typical application, that is over 27 minutes of database CPU time saved per minute of wall clock time. The database serves the same load with measurably less work.
What to do on Monday morning
You have been patient, and I have been thorough. Allow me to distil this into a sequence of actions, ordered by impact and risk.
- Enable
pg_stat_statementsif you have not already. This is a prerequisite for any data-driven optimisation. Addpg_stat_statementstoshared_preload_librariesinpostgresql.conf, restart PostgreSQL, and runCREATE EXTENSION pg_stat_statements. Let it collect data for at least 24 hours before drawing conclusions. - Query your planning overhead. Use the
pg_stat_statementsquery from the measurement section above. Identify your top 20 queries bytotal_plan_time. Calculateplan_pctfor each. If your highest-impact queries show planning overhead below 10% of total time, the plan cache optimisation is not your priority — focus on indexing or query structure instead. - Switch to native prepared statements. Set
PDO::ATTR_EMULATE_PREPARES = falsein your framework configuration. This is low-risk, provides immediate security improvements, and is a prerequisite for any plan cache benefit. Test your application's full test suite after the change. - Evaluate your connection path. If you are connecting directly to PostgreSQL without a pooler, add one. PgBouncer is the established choice. If you are already on PgBouncer, verify you are on 1.21+ for prepared statement support. If plan cache activation is a priority and your
pg_stat_statementsdata supports it, evaluate a proxy that maintains long-lived upstream connections. - Monitor after changes. After any configuration change, compare
pg_stat_statementsmetrics from before and after. The numbers should confirm or refute the expected improvement. If they do not match expectations, you have learned something valuable about your workload.
The plan cache optimisation is not glamorous. It does not involve new technology, architectural redesigns, or hardware upgrades. It involves understanding how PostgreSQL works at the protocol level, measuring where your application's time is being spent, and ensuring that a forty-year-old database optimization can actually reach the queries it was designed to help.
Your queries were always worth caching. They just needed a connection that lived long enough to prove it.
Frequently asked questions
Terms referenced in this article
The matter of plan caching deserves more attention than I can give it here in the PHP context alone. I have written a practical guide to pg_stat_statements that shows how to identify the queries where PostgreSQL's planner spends the most time — and whether custom plans or generic plans are winning the argument for each of your prepared statements.