A SELECT that Postgres executed in 0.221 ms spent 10.65 ms being planned, and the counter that would have shown you is off by default. That case is documented by Tiger Data on Postgres 16 against a table split into 500 partitions. Meanwhile the transport that carried the query — real TCP, across two internal network hops — costs about 0.42 ms, measured by Cybertec with pgbench on PostgreSQL 17.5 in December 2025.

Two numbers, one query, and the expensive one is not the one anybody blames.

The working model most of us carry has two stages: the network hop, and the database doing work. Add a pool, the model says, and the hop gets cheap. That model is useful — it predicts the right fix often enough to survive — and it collapses the moment you ask which of five stages your particular latency is actually sitting in. Connection setup, protocol framing, planning, execution and transport each have a separately measurable cost and a failure mode of their own. Which one dominates changes completely between a service in the same availability zone and a function on someone else's edge.

Connecting costs three handshakes before any query exists

Before a byte of SQL moves, the client completes a TCP handshake, then asks for encryption with an SSLRequest packet the server answers with a single byte (S or N), then sends a StartupMessage, then authenticates. Under SCRAM — the default password_encryption since Postgres 14 — authentication alone is three more round trips: AuthenticationSASL, SASLInitialResponse, AuthenticationSASLContinue, SASLResponse, AuthenticationSASLFinal. Only then do BackendKeyData, the ParameterStatus messages and the first ReadyForQuery arrive.

Postgres core contributor Michael Paquier benchmarked what SCRAM itself costs by varying its iteration count, using pgbench -C on localhost against Postgres 16 so that connection setup was the whole workload.

Average connect time, SCRAM at 1 iteration vs the 4,096 default
1.6314.226ms

pgbench -n -T 30 -C, connect/disconnect loop, localhost, PostgreSQL 16; hardware not stated. The gap is the client's PBKDF2 work, not network.

Roughly 2–3 ms, and it is CPU on the client rather than time on the wire. On localhost that is most of the connection cost. Put the same handshake on a WAN link and it disappears into the round trips, which is the first hint that this article's answer is going to be "it depends where you are".

Every parameterised query already speaks the extended protocol

The simple query protocol is one message: Query, carrying SQL as text, possibly several statements separated by semicolons, results always in text format. The extended protocol replaces it with a sequence — Parse, Bind, Describe, Execute, Sync — and the Postgres docs are explicit that the simple form is "approximately equivalent" to that sequence run against the unnamed statement and portal with no parameters.

Most application code is already on the extended path without opting in. In node-postgres, the query implementation sends a plain Query message only when there is no non-empty values array, no statement name and no row limit. One parameter is enough to switch protocols.

Speed is not why the extended protocol exists. The libpq documentation frames it around parameter separation: "it is neither necessary nor correct to do escaping when a data value is passed as a separate parameter", and the one-statement-per-Parse restriction is "a limitation of the underlying protocol, but has some usefulness as an extra defense against SQL-injection attacks". Reuse and pipelining are consequences of splitting parse from bind, not the stated goal.

Sync is the message that costs you. It marks the end of a series, closes an implicit transaction, and always produces ReadyForQuery — which is why a driver that sends Sync after every statement gets one full round trip per statement no matter how the protocol is framed. Pipelining, added to libpq in Postgres 14, exists to break that coupling; the docs illustrate the stakes with arithmetic rather than a benchmark, noting that 100 statements over a 300 ms link would spend 30 seconds in pure network latency serially against roughly 0.3 s pipelined. Arithmetic, not a measurement — but the ratio is just the round-trip count, and that part is real.

Three layers call it a prepared statement, and they disagree

Here is where the mechanism gets genuinely confusing, because the word "prepare" names three different things at three different layers, and two of them happen to use the number five.

LayerWhat it doesDefault triggerScope
Driver statement cache — pgJDBC prepareThreshold, psycopg3 prepare_thresholdStops using the unnamed statement, issues a named Parse5th execution of the same SQLOne physical connection
Wire protocol — Parse with a non-empty nameServer keeps the parse tree and a plancache entry alive past MessageContext resetDriver's choiceThe session; dropped on disconnect
Planner — plan_cache_modeReplaces per-parameter custom plans with one parameter-blind generic planAfter 5 custom plans, if the generic estimate is cheaperThat prepared statement

pgJDBC runs the first four executions through the extended protocol using the unnamed statement and only promotes on the fifth. psycopg3 does the same thing at prepare_threshold=5, capped at 100 live statements per connection with LRU eviction; psycopg2 never did it at all. And Postgres itself counts to five independently: "the first five executions of a prepared statement are done with custom plans and the average estimated cost of those plans is calculated", after which a generic plan is built and compared.

The scope column is the one that matters operationally. A named prepared statement is cached server-side on one physical connection. Two connections in the same pool parse and plan the same SQL twice, and everything is discarded on disconnect. Prepared statements do not amortise across a pool.

Planning is a separate cost and your metrics hide it

pg_stat_statements.track_planning has existed since Postgres 13 and is off by default. With it off, pg_stat_statements reports execution time and nothing else, so a slow-query log built on execution time is structurally blind to a query that is slow to plan.

The Tiger Data case is what that blindness hides. The query was SELECT * FROM device_metrics WHERE device_id = 8 AND ts > now() - interval '7 days' against 500 partitions. Postgres treats now() as stable rather than constant, so it cannot prune partitions at plan time; it builds subplans for all 500 and discards 492 at execution.

Planning time, now() versus a fixed timestamp literal
10.650.183ms

PostgreSQL 16, 500 partitions, track_planning on, aggregated over 20,000 calls. A single practitioner report from a vendor selling Postgres products, not a controlled lab run — the mechanism is reproducible, the exact figures are one case.

Across 20,000 calls that was 213 seconds of planning, 99.7% of all planning time on that server, for queries whose execution time looked unremarkable in every dashboard.

Plan caching is not a free win in the other direction either. The generic plan is chosen on estimated cost with the parameters unknown, and practitioners at dbi-services and pganalyze both document skewed data and high partition counts where the generic plan is the slower one. The honest rule is conditional: caching helps unless your parameter distribution is skewed or your table is heavily partitioned, and plan_cache_mode exists precisely because the heuristic gets it wrong often enough to need an override.

The pool saves the handshake and can cost you the plan

An application pool — pg.Pool, HikariCP — keeps authenticated connections open and hands them out. It removes TCP, TLS and SCRAM from the per-query path, and because a checked-out client keeps the same backend for the duration, it also preserves whatever session and plan state accumulated there. It does nothing about parse and plan cost for a query that connection has not seen.

A network proxy pooler — PgBouncer, Supavisor, RDS Proxy — does something different. It multiplexes many client connections onto fewer backends, which is the only thing that lets an app scale past Postgres's one-process-per-connection architecture. What it saves the application is still just the handshake. What it costs, in the default transaction mode, is session state.

Percona's PgBouncer scaling benchmark is the number people skip.

Throughput, direct connections versus PgBouncer transaction poolingsysbench-tpcc, PgBouncer 1.8.1, 56-core host, shared_buffers at 75%, 30-minute runs, scale 100; Postgres version not stated. Percona, June 2018 — old software, but the shape is the finding, not the absolute values.
Direct, 56 clients
5 000TPS
PgBouncer pool=56, 56 clients
2 000TPS
PgBouncer pool=56, 600 clientsdirect would collapse here
2 000TPS

At matched concurrency the pooler loses, by more than half. Its value shows up in the third bar: throughput stays flat from 56 to 600 clients, where 600 real backend connections would have fallen over. That is not a latency optimisation. It is admission control against a hard ceiling — and the ceiling is what every managed provider actually publishes. AWS derives max_connections from a memory formula, LEAST(DBInstanceClassMemory/9531392, 5000). Neon ties it to compute size, 104 connections at 0.25 CU up to a hard cap of 4,000. Supabase caps direct connections at 500 on every tier from 12XL upward, with the pooler as the only path past it. All three scale with RAM. None of them scale with how fast your queries are.

Transaction pooling pays for that ceiling by giving up session scope. PgBouncer's own feature list names what breaks: SET/RESET, LISTEN, WITHHOLD cursors, PREPARE/DEALLOCATE, temp tables, LOAD. RDS Proxy takes the opposite approach and pins the client to one backend when it sees those statements, silently disabling multiplexing for that session — including when it sees DISCARD ALL, which is the standard PgBouncer reset query.

So the layer that removed the handshake removed the plan cache with it. Fourteen years of bug reports are that one sentence, rediscovered by a new ecosystem each time.

  1. June 2011

    rails/rails #1627

    the first tracker hit

    ActiveRecord against PgBouncer in transaction mode, prepared statements missing. The problem class predates every tool in this article's pooling section.

  2. September 2020

    brianc/node-postgres #2327

    "Transaction pooling with node-postgres does not seem possible for prepared statements", called a major roadblock for serverless applications. SQLAlchemy filed the same shape in May 2021 (#6467), Prisma in December 2020 (#4752).

  3. 16 October 2023

    PgBouncer 1.21.0

    the prepared-statements release

    PgBouncer starts intercepting client Parse messages, renaming statements to PGBOUNCER_n and re-preparing them on whichever backend a transaction lands. Release notes claim 15–250% throughput improvement.

  4. 26 October 2023

    prisma/prisma #21635

    closed as not planned

    Ten days later: PgBouncer does not track Prisma's DEALLOCATE ALL, its statement map goes stale, and clients get prepared statement "PGBOUNCER_x" does not exist. A PHP/PDO version (pgbouncer #991) followed in December, a Rust tokio-postgres one (#1067) in May 2024.

  5. 3 September 2025

    psycopg/psycopg #1151

    open

    psycopg 3.2.9, PgBouncer 1.24, Postgres 17.5: prepared statements silently re-created on every query instead of reused. A month earlier, rails #55468 asked whether the Rails guide's PgBouncer warning was finally stale. Neither is resolved.

Which stage dominates is decided by deployment shape

Put the measured costs on one axis and the question answers itself. Same-machine, transport is 0.019 ms over a Unix socket. Cross-continent, it is 175 ms, and every protocol-level cost in this article is noise.

One round trip to the database, by distanceFirst three bars: Cybertec, pgbench on PostgreSQL 17.5, 20-second read-only runs, December 2025. Last two: CloudPing.co continuous TCP-connect probes, accessed September 2026. Two harnesses, one unit — read the ladder, not the individual gaps. · lower is better
Unix socket, localhost
0.019ms
TCP, localhost
0.034ms
TCP, 2 internal hops
0.420ms
AWS same region
1.81ms
us-east-1 to eu-west-1
175.92ms

Cybertec's injected-latency test shows what that does to throughput: with 10 concurrent pgbench clients, 8,813 TPS at baseline becomes 462 TPS with 10 ms of added RTT and 90 TPS at 50 ms. Average query latency goes 1.135 ms → 21.66 ms → 112.6 ms. Roughly linear in the round trips, which is the signature of a workload bounded by distance rather than by work.

Now the serverless regime, where connection setup stops being amortised because there is no long-lived process to amortise it into.

Getting a usable connection from a serverless runtimeMedian of 30 invocations per scenario, Neon in eu-central-1, client in Bulgaria, pg versus @neondatabase/serverless. A single independent run whose stated Postgres version conflicts with its 2024 publish date — treat as indicative, not definitive. · lower is better
Cold direct connect
341.1ms
Through Neon's TCP poolerstill a cold handshake
312.7ms
HTTP driver, one-shot
43.2ms
Warm pooled connection
39.4ms

The second bar is the instructive one. Routing a cold connection through a pooler saved 28 ms, because the client still pays TCP, TLS and SCRAM across a long WAN hop — the pooler removed the work on the database side of itself, not on yours. The 8x win comes from a connection that already exists, or from Neon's HTTP driver, which abandons the session model entirely and sends one query per fetch request.

That is the pattern underneath every layer in this article. An application pool moves the cost to "which physical connection has the plan I need". Transaction-mode PgBouncer moves it to "did my session state survive the swap". RDS Proxy moves it to "did this statement just pin me". The HTTP driver moves it to "you no longer have a session at all". None of them delete the cost.

Where this explanation stops

Execution and storage are a stage this article has treated as a black box, and for a query reading resident pages that is roughly fair — shared_buffers is a dedicated shared-memory region with 8 KB pages and a clock-sweep eviction ring, and a page evicted from it often still lives in the OS page cache. Commit is the exception: synchronous commit makes the server wait for WAL to reach permanent storage before replying, and the docs state plainly that "for short transactions this delay is a major component of the total transaction time". A write path has a sixth stage that a read path does not.

Postgres 18, GA on 25 September 2025, added an asynchronous I/O subsystem with a new io_method setting, and the project's own announcement claims up to 3x faster storage reads in certain scenarios, with no stated hardware or workload. Christophe Pettus of PGX points out the catch: the fast path is io_uring, which Red Hat disables on RHEL 9 and most managed Kubernetes runtimes disable for security reasons, leaving those deployments on the worker fallback.

Two gaps in the public record shape what anyone can honestly claim here. No independent, methodology-disclosed benchmark isolates parse-plus-plan cost in absolute milliseconds for a simple parameterised query, first execution versus cached. And nobody has stacked bare TCP, TCP+TLS and full SCRAM authentication in one consistent harness — the pieces exist in separate tests on separate hardware, which is why the connection-setup figures above come from a single WAN comparison rather than a decomposition.

What follows for the code you have

Turn on pg_stat_statements.track_planning. It is one setting, it is off by default, and until it is on, an entire stage of this path is missing from your data. A 48x planning-to-execution ratio produces no slow-query log entry at all.

Stop expecting a pool to make queries faster. It makes connections free and concurrency survivable, and Percona's numbers show it costing throughput at matched concurrency. The question a pool answers is "how many clients can I have", which is a capacity question with a RAM-shaped answer at every provider.

Check which prepare you are talking about before debugging one. A driver threshold, a named Parse, and the planner's generic-plan switch are three mechanisms with three scopes, and the fix for a problem in one of them is usually inert against the other two.

And if your database is in another region, everything above is a rounding error. 175 ms of RTT is not tunable from inside Postgres.