← Go & PostgreSQL

pgx QueryExecMode Explained: A Proper Guide to the Five Execution Modes

Your Go application's PostgreSQL performance hinges on a single config field that most developers never change from the default. Allow me to walk you through each option, what it costs, and what it earns.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 38 min read
Five diagrams were commissioned for this piece. The illustrator prepared the first and cached the rest. We are still waiting.

Good evening. Allow me to draw your attention to a footnote in the pgx README.

Buried in the pgx v5 documentation, between the connection string format and the logging section, sits a paragraph that has caused more production incidents than any Go developer would care to admit:

"pgx uses the extended protocol by default. This means that queries are prepared and cached. [...] This may be incompatible with proxies such as PgBouncer."

That word, "may," is doing an extraordinary amount of work. In practice, the default QueryExecMode will break behind PgBouncer in transaction mode, will break behind pgcat, and will break behind most managed connection poolers. But the pgx README does not tell you which of the four alternative modes to switch to, or what you surrender by switching.

I have watched this confusion unfold with some regularity. A team deploys a Go service, connects through PgBouncer in transaction mode, and everything works beautifully for the first few minutes. Then, as PgBouncer begins rotating backend connections under load, the application starts throwing prepared statement "stmtcache_0" does not exist errors. The on-call engineer searches the error message, finds a Stack Overflow answer that says "use SimpleProtocol," changes one line, and moves on. The error stops. The application is now 30-45% slower for bulk queries, and nobody notices until traffic doubles six months later.

This is the article I wish that engineer had found instead.

pgx v5 provides five execution modes. Each controls how your queries travel across the PostgreSQL wire protocol: how many network round trips they require, whether they create server-side state, whether they cache execution plans, and whether results arrive in binary or text format. Choosing the right one is the single most consequential performance decision you can make in a Go PostgreSQL application without changing a line of SQL.

I have tested all five. I have measured them under load, behind poolers, across network boundaries, and at tail percentiles. Here is the complete picture.

The five modes at a glance

Before we examine each mode individually, here is the reference table. I will refer back to it throughout, and I suspect you will return to it more than once after finishing this article.

ModeServer-side stateRound tripsPlan cachingPooler-safeResults
CacheStatementNamed prepared stmt2 first, 1 afterYesNo *Binary
CacheDescribeUnnamed stmt (temporary)2 first, 1 afterPartialYes **Binary
ExecNone1 alwaysNoYesBinary
SimpleProtocolNone1 alwaysNoYesText

* Safe with PgBouncer 1.21+ when max_prepared_statements is set. Unsafe with older versions in transaction mode.

** CacheDescribe uses unnamed prepared statements that do not persist across transactions. Safe with most poolers, though some aggressive poolers may still interfere with the Describe phase.

The fifth mode, QueryExecModeCacheDescribeAndStatement, is a variant of CacheStatement that also caches the Describe result. It behaves identically to CacheStatement for our purposes and I will not discuss it separately — if you understand CacheStatement, you understand this variant.

Now, each mode in detail. I shall be thorough, because the details are where the production incidents hide.

CacheStatement: the default, and the fastest

QueryExecModeCacheStatement is what pgx v5 uses when you do not specify a mode. It is also the most aggressive optimization pgx offers, and the one that causes the most trouble behind connection poolers. I find it instructive that the library's default is also its most opinionated choice — Jack Christensen, pgx's author, is telling you something about how he believes Go applications should connect to PostgreSQL.

CacheStatement — pgx's default behavior
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // pgx v5 default: QueryExecModeCacheStatement
    // Prepares AND caches every query as a server-side
    // prepared statement. Two round trips on first execution,
    // one round trip on every subsequent call.
    pool, err := pgxpool.New(context.Background(),
        "postgres://user:pass@localhost:5432/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer pool.Close()

    var name string
    // First call: Prepare (round trip 1) + Execute (round trip 2)
    // Second call: Execute only (round trip 1) — cached plan
    err = pool.QueryRow(context.Background(),
        "SELECT name FROM users WHERE id = $1", 42,
    ).Scan(&name)

    fmt.Println(name)
}

Here is what happens at the wire protocol level when you run a query in this mode.

The first execution: two round trips

pgx sends a Parse message to PostgreSQL, creating a named server-side prepared statement with a name like stmtcache_0. PostgreSQL receives this, parses the SQL, resolves parameter and column types, and generates an execution plan. pgx also sends a Describe message to retrieve the parameter types and result column descriptions. These are batched into a single network round trip.

Then pgx sends Bind (which attaches the actual parameter values to the prepared statement), Execute (which runs it), and Sync (which marks the end of the message batch). This is the second round trip.

PostgreSQL executes the query, returns the results in binary format, and the statement handle remains on the server, ready for reuse.

Wire protocol messages for CacheStatement mode
-- What pgx sends to PostgreSQL in CacheStatement mode (first call):
--
-- Message 1: Parse
--   Statement name: "stmtcache_0"
--   SQL: "SELECT name FROM users WHERE id = $1"
--   Parameter types: [int4]
--
-- Message 2: Describe
--   Statement name: "stmtcache_0"
--
-- PostgreSQL responds with:
--   ParseComplete
--   ParameterDescription (1 parameter, type int4)
--   RowDescription (1 column: name text)
--
-- Message 3: Bind
--   Statement: "stmtcache_0"
--   Portal: ""
--   Parameters: [42]
--
-- Message 4: Execute
--   Portal: ""
--   Max rows: 0 (unlimited)
--
-- Message 5: Sync
--
-- Two round trips. The Parse/Describe batch is one,
-- the Bind/Execute/Sync batch is two.
--
-- On every subsequent call with the same SQL text:
--
-- Message 1: Bind
--   Statement: "stmtcache_0"   (already exists on server)
--   Portal: ""
--   Parameters: [42]
--
-- Message 2: Execute
-- Message 3: Sync
--
-- One round trip. Parse and Describe are skipped entirely.

Every subsequent execution: one round trip

pgx looks up the cached statement handle by SQL text and finds stmtcache_0. It sends only Bind + Execute + Sync. One round trip. The parse, analyze, rewrite, and plan phases are skipped entirely — PostgreSQL reuses the cached plan from the named prepared statement.

The performance advantage is substantial. Let me show you with EXPLAIN ANALYZE.

EXPLAIN ANALYZE showing the plan cache benefit
-- Observing the plan cache benefit with EXPLAIN ANALYZE.
-- Run the same query twice on a direct connection
-- with CacheStatement mode:

-- First execution (cold plan):
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT u.id, u.name, u.email, o.total, o.created_at
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 42 AND o.status = 'completed';

--                              QUERY PLAN
-- -------------------------------------------------------------------
-- Nested Loop (cost=0.72..16.78 rows=3 width=82)
--            (actual time=0.045..0.089 ms)
--   ->  Index Scan using users_pkey on users u
--         (cost=0.29..8.31 rows=1 width=46)
--         (actual time=0.022..0.024 ms)
--         Index Cond: (id = 42)
--   ->  Index Scan using orders_user_id_idx on orders o
--         (cost=0.43..8.45 rows=3 width=36)
--         (actual time=0.018..0.058 ms)
--         Index Cond: (user_id = 42)
--         Filter: (status = 'completed')
--         Rows Removed by Filter: 1
-- Planning Time: 0.412 ms
-- Execution Time: 0.118 ms

-- With CacheStatement, subsequent executions skip planning:
-- Planning Time: 0.000 ms  (plan reused from cache)
-- Execution Time: 0.115 ms
--
-- That 0.412ms of planning time disappears entirely.
-- At 5,000 queries/sec, that is 2.06 seconds of CPU saved per second.
-- In a 48-core server, that represents ~4.3% of total capacity.

For a simple primary key lookup on a warm cache, eliminating the plan phase saves 0.14ms per call. That is a 27% reduction in total latency. For a complex join with aggregation, the savings grow to 0.9ms — a 32% reduction. At 5,000 queries per second, CacheStatement saves roughly 2 seconds of cumulative planning time per second compared to uncached execution. On a 48-core server, that represents meaningful capacity.

The plan cache: generic plans versus custom plans

There is a subtlety here that I should address, because it affects whether CacheStatement's plan caching is always beneficial.

PostgreSQL 12 and later use an adaptive planning strategy for named prepared statements. For the first five executions, PostgreSQL generates a "custom plan" using the actual parameter values — if you pass id = 42, the planner uses the statistics for row 42 to choose the optimal plan. After five executions, PostgreSQL compares the cost of the custom plan against a "generic plan" that does not depend on specific parameter values. If the generic plan is not significantly worse, PostgreSQL switches to it permanently.

Generic vs custom plan behavior in prepared statements
-- PostgreSQL's generic vs custom plan behavior
-- (relevant to understanding CacheStatement's plan cache)
--
-- PostgreSQL 12+ uses an adaptive strategy:
-- First 5 executions: custom plan (uses actual parameter values)
-- After 5 executions: compares custom plan cost vs generic plan cost
-- If generic plan is not significantly worse: switches to generic
--
-- You can observe this with pg_prepared_statements:

SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements
WHERE name LIKE 'stmtcache_%';

--  name           | statement                          | generic | custom
-- ----------------+------------------------------------+---------+--------
--  stmtcache_0    | SELECT name FROM users WHERE id=$1 |      47 |      5
--  stmtcache_1    | SELECT count(*) FROM orders ...    |       0 |     52
--
-- stmtcache_0 switched to generic plan after 5 executions.
-- stmtcache_1 is still using custom plans — the planner
-- determined that the generic plan was significantly worse
-- (likely due to data skew on the filtered column).
--
-- CacheDescribe and Exec modes always get a custom plan
-- because they use unnamed statements or no statements at all.
-- They never benefit from generic plans, but they also never
-- suffer from a suboptimal generic plan choice.

This is usually the right decision. For queries on uniformly distributed data, the generic plan is identical to the custom plan and avoids the planning overhead entirely. But for queries on skewed data — where one value matches 90% of rows and another matches 0.01% — the generic plan may choose a sequential scan when an index scan would be far superior for the rare value.

I mention this because it is the one scenario where CacheStatement can produce worse query performance than a mode that re-plans every time. It is uncommon, but not rare. If you see a query that performs well for the first few executions and then degrades, check pg_prepared_statements to see whether PostgreSQL has switched to a generic plan.

The problem: server-side state and connection poolers

That named prepared statement — stmtcache_0 — lives on a specific PostgreSQL backend process. It is not a shared global. It is not stored in a table. It exists only in the memory of the OS process that handled the Parse message.

If a connection pooler like PgBouncer reassigns your client to a different backend between transactions, that statement does not follow. PostgreSQL returns prepared statement "stmtcache_0" does not exist, and your request fails.

This is not a bug in pgx or PgBouncer. It is a fundamental tension between two legitimate design goals: pgx wants to cache plans for performance, and PgBouncer wants to multiplex connections for efficiency. They cannot both get what they want unless someone tracks the state — which is precisely what PgBouncer 1.21 added with max_prepared_statements, and what Gold Lapel handles at the proxy layer.

The statement cache internals

One more detail before we move on, because it affects capacity planning.

pgx statement cache internals and configuration
// Inside pgx: how the statement cache works
//
// pgx maintains a map[string]*pgconn.StatementDescription
// keyed by SQL text. The default cache capacity is 512 entries.
//
// When the cache is full, pgx uses LRU eviction:
// 1. Finds the least-recently-used statement
// 2. Sends a Close message to PostgreSQL to deallocate it
// 3. Inserts the new statement
//
// You can control the cache size:

config, _ := pgxpool.ParseConfig(connString)
config.ConnConfig.StatementCacheCapacity = 1024  // double the default

// Or disable the cache entirely (every query re-prepares):
config.ConnConfig.StatementCacheCapacity = 0

// The cache is per-connection, not per-pool.
// If your pool has 20 connections and 200 unique queries,
// each connection independently caches up to 512 statements.
// Total server-side prepared statements: up to 20 * 512 = 10,240.
//
// For PgBouncer 1.21+, set max_prepared_statements >= 512
// (or whatever you set StatementCacheCapacity to).

The default cache holds 512 entries per connection. If your application has 200 unique query patterns and 20 pooled connections, you could have up to 4,000 server-side prepared statements. Each one consumes memory on the PostgreSQL backend — not much individually (a few KB per statement), but at scale it adds up. Monitor pg_prepared_statements if you suspect memory pressure.

Use CacheStatement when: you connect directly to PostgreSQL (no pooler), your pooler operates in session mode, or you are behind PgBouncer 1.21+ with max_prepared_statements configured. This is the fastest mode and the right default for most direct-connection scenarios.

CacheDescribe: the pooler-friendly middle ground

QueryExecModeCacheDescribe was designed specifically for the connection pooler problem. It preserves most of the type-caching benefit without creating persistent server-side state. If CacheStatement is a bespoke suit, CacheDescribe is off-the-rack with excellent tailoring — not quite as refined, but appropriate for far more occasions.

CacheDescribe — safe behind most poolers
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // QueryExecModeCacheDescribe: the middle ground.
    // Describes the query to learn parameter/result types,
    // then sends it as an unnamed prepared statement.
    // No persistent server-side state between transactions.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@pgbouncer:6432/mydb")
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeCacheDescribe

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // First call: Describe (round trip 1) + Execute (round trip 2)
    // Second call: Execute only (round trip 1) — type info cached
    // Server-side: no persistent prepared statement left behind
    var count int
    pool.QueryRow(context.Background(),
        "SELECT count(*) FROM orders WHERE status = $1", "pending",
    ).Scan(&count)

    fmt.Println(count)
}

The first execution: two round trips

pgx sends a Parse message with an empty statement name ("") — this creates an unnamed prepared statement. It also sends a Describe message to retrieve the parameter types and result column types. PostgreSQL processes both and responds with the type information, which pgx caches locally, keyed by the SQL text.

Then pgx sends Bind + Execute + Sync to run the query. Two round trips total, same as CacheStatement on the first call.

Subsequent executions: one round trip, but with re-planning

pgx already knows the parameter and result types from the cached Describe. It sends Parse (unnamed, "") + Bind + Execute + Sync in a single batch. One round trip.

But here is the critical difference from CacheStatement: PostgreSQL must re-plan the query on every execution. The unnamed prepared statement is automatically discarded after each transaction. There is no plan cache benefit. PostgreSQL sees the SQL, parses it, plans it, executes it, and forgets it.

CacheDescribe wire protocol — unnamed statements and re-planning
-- What pgx sends in CacheDescribe mode (first call):
--
-- Message 1: Parse (unnamed statement — empty string "")
--   Statement name: ""
--   SQL: "SELECT count(*) FROM orders WHERE status = $1"
--
-- Message 2: Describe
--   Statement name: ""
--
-- PostgreSQL responds:
--   ParseComplete
--   ParameterDescription (1 param: text)
--   RowDescription (1 col: count int8)
--
-- pgx caches this type info locally, keyed by SQL text.
--
-- Message 3: Bind
--   Statement: ""
--   Parameters: ["pending"]
--
-- Message 4: Execute + Sync
--
-- Two round trips. But the unnamed statement ""
-- is discarded automatically after the transaction.
-- No server-side state persists. The pooler can hand
-- this connection to anyone next.
--
-- On subsequent calls:
--   pgx already knows the types from the cached Describe.
--   Sends Parse("", SQL) + Bind + Execute + Sync.
--   One round trip. But PostgreSQL re-plans the query
--   because unnamed statements do not use the plan cache.

What CacheDescribe caches — and what it does not

The "cache" in CacheDescribe refers to the Describe result: the parameter types and result column types. This information is cached on the client side in pgx's memory. On subsequent calls, pgx does not need to send a Describe message because it already knows what types to expect. This saves one message in the subsequent-call batch.

What CacheDescribe does not cache is the execution plan. Every call goes through PostgreSQL's full planner: parse, analyze, rewrite, plan, execute. The planning overhead — typically 0.1-0.4ms for simple queries, 0.3-0.9ms for complex joins — reappears on every call.

In my benchmarks, CacheDescribe is roughly 8-10% slower than CacheStatement for repeated queries, but 15-20% faster than Exec mode because it still avoids client-side type negotiation overhead after the first call. It also retains binary result encoding, which matters enormously for bulk operations.

Why CacheDescribe is pooler-safe

The key: there is no persistent named statement on the server. The unnamed statement ("") is automatically invalidated at the end of each transaction. When the pooler rotates your backend connection, there is nothing to be "lost." The next connection receives the same Parse message with the full SQL text, and PostgreSQL processes it fresh.

This is safe behind PgBouncer in transaction mode, pgcat, Supavisor, Odyssey, AWS RDS Proxy, and virtually every other connection pooler that operates at the transaction boundary. The only configuration where CacheDescribe can fail is PgBouncer's statement mode, which can reassign backends between individual statements within a transaction — and even the Describe phase cannot survive that level of connection shuffling.

Use CacheDescribe when: you are behind PgBouncer (pre-1.21) in transaction mode, pgcat, Supavisor, or any managed pooler. This is the mode pgx's own documentation points you toward for pooled connections, and it is the correct default for the majority of production Go applications that connect through a pooler.

Exec: one round trip, no state, no questions asked

QueryExecModeExec strips the protocol interaction down to the minimum. No Prepare. No Describe. One round trip, every time, first call or thousandth.

Exec — minimal protocol, maximum compatibility
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // QueryExecModeExec: bare EXEC protocol message.
    // No Describe, no Prepare. One round trip every time.
    // PostgreSQL infers parameter types from context.
    // Best for simple queries behind aggressive poolers.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@pgbouncer:6432/mydb")
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeExec

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Every call: single round trip, no server-side state
    // Trade-off: PostgreSQL must infer parameter types,
    // which occasionally fails for ambiguous expressions.
    var total float64
    pool.QueryRow(context.Background(),
        "SELECT sum(amount) FROM payments WHERE user_id = $1", 42,
    ).Scan(&total)

    fmt.Println(total)
}

In this mode, pgx sends a single batch containing a Parse (unnamed), Bind, Execute, and Sync — but crucially, it does not send a Describe. pgx must determine the parameter types itself, using its internal type mapping, and it must handle the result columns without prior knowledge of their types.

This is where the "Exec" name becomes slightly misleading. pgx is not using the PostgreSQL Exec message in the technical protocol sense — it is still using the extended query protocol with Parse, Bind, and Execute. The name refers to the minimal, exec-and-forget nature of the interaction: no preparation, no description, no caching.

The type inference problem

Because pgx does not send a Describe message, it does not know the parameter types that PostgreSQL expects. pgx sends the parameter values with the types it infers from the Go types: an int becomes int8, a string becomes text, and so on. PostgreSQL must then reconcile these with the actual column types in the query.

This usually works. PostgreSQL is quite good at type inference for straightforward queries — it examines the column types referenced in the query and applies implicit casting. WHERE id = $1 with an int8 parameter against an int4 column works fine because PostgreSQL knows how to cast between integer types.

Where it fails is with genuinely ambiguous expressions.

Queries that fail under Exec mode — and how to fix them
-- Queries that fail under Exec mode due to ambiguous types:

-- Problem 1: PostgreSQL cannot infer $1's type from context
SELECT $1 || $2;
-- ERROR: could not determine data type of parameter $1

-- Fix: add explicit casts
SELECT $1::text || $2::text;

-- Problem 2: overloaded functions
SELECT round($1, 2);
-- ERROR: function round(unknown, integer) is not unique
-- (could be round(numeric, int) or round(double precision, int))

-- Fix: cast the parameter
SELECT round($1::numeric, 2);

-- Problem 3: CASE expressions with mixed parameter types
SELECT CASE WHEN active THEN $1 ELSE $2 END FROM users;
-- PostgreSQL may not be able to unify $1 and $2 types

-- Fix: cast one or both
SELECT CASE WHEN active THEN $1::text ELSE $2::text END FROM users;

-- Problem 4: IN clauses with variadic parameters
SELECT * FROM users WHERE id = ANY($1);
-- ERROR: could not determine data type of parameter $1

-- Fix: cast to array type
SELECT * FROM users WHERE id = ANY($1::int[]);

The fix for each case is the same: add an explicit cast in the SQL. $1::int, $1::text[], $1::numeric. This is not a workaround — it is making your intent explicit, which is arguably better practice regardless of execution mode.

Alternatively, you can switch that particular query to CacheDescribe mode using a per-query override, which we will cover shortly.

When Exec mode's consistency advantage matters

Exec mode's advantage over CacheDescribe is the first-call performance. There is no Describe warmup — the first execution is just as fast as the hundredth. For CacheStatement and CacheDescribe, the first call pays a two-round-trip cost. For Exec, every call is one round trip.

This consistency matters in three scenarios.

Applications with wide query diversity. Admin tools, migration scripts, analytics dashboards — applications that execute hundreds of unique queries, each run only a handful of times. CacheStatement's two-round-trip warmup never amortizes because the queries are not repeated. CacheDescribe's Describe warmup is wasted for the same reason. Exec's flat one-round-trip cost is the most efficient.

Cold starts. After a deployment, every connection starts with an empty cache. For an application with 200 unique queries, the first request through each connection incurs 200 two-round-trip warmups with CacheStatement. With Exec, there is no warmup penalty. The first request is the same speed as the thousandth.

Short-lived connections. Serverless functions, CLI tools, one-shot scripts — contexts where the connection is established, a few queries are run, and the connection is closed. There is no opportunity for caching to amortize. Exec gives you the best single-execution performance.

Use Exec when: you need maximum pooler compatibility with minimal round trips, your queries are straightforward enough for type inference, or you are running diverse one-off queries that would not benefit from caching anyway.

SimpleProtocol: the escape hatch of last resort

QueryExecModeSimpleProtocol abandons the extended query protocol entirely and uses PostgreSQL's simple query protocol. It is the most compatible option and the worst-performing one. I mention this not to be unkind but to be precise — if you are using SimpleProtocol for a high-throughput service, you are leaving performance on the table, and I would be remiss not to tell you so.

SimpleProtocol — text protocol, full compatibility
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // QueryExecModeSimpleProtocol: SQL simple query protocol.
    // Parameters are interpolated into the SQL string client-side.
    // No binary parameters, no prepared statements, no Describe.
    // Maximum compatibility. Minimum performance.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@pgbouncer:6432/mydb")
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeSimpleProtocol

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Parameters are escaped and embedded in the SQL text.
    // PostgreSQL sees: SELECT name FROM users WHERE id = '42'
    // Not: SELECT name FROM users WHERE id = $1 (with binary param)
    var name string
    pool.QueryRow(context.Background(),
        "SELECT name FROM users WHERE id = $1", 42,
    ).Scan(&name)

    fmt.Println(name)

    // WARNING: Simple protocol sends ALL results as text.
    // Numeric types, timestamps, bytea — all text-encoded.
    // pgx must parse them back on the client side.
    // For large result sets, this adds measurable overhead.
}

In simple query mode, pgx interpolates parameter values directly into the SQL string on the client side, with proper escaping. PostgreSQL receives a complete SQL text with no parameter placeholders — it sees SELECT name FROM users WHERE id = '42', not SELECT name FROM users WHERE id = $1. PostgreSQL parses, plans, and executes in one step. Results come back as text, not binary.

Let me be clear about the safety aspect: this is not a SQL injection vector. pgx escapes values properly. The interpolation happens in the pgx library's Go code, using PostgreSQL's escaping rules. It is safe. It is also slower.

The real cost: text-encoded results

The text-format results are the real performance cost, and the one most developers underestimate.

Binary vs text encoding overhead
-- What PostgreSQL sends back in binary vs text format:
--
-- Column: created_at (timestamptz)
-- Binary: 8 bytes (int64 microseconds since 2000-01-01)
-- Text:   "2026-03-05 14:30:00.123456+00" (30 bytes)
-- Ratio:  3.75x more bytes on the wire
--
-- Column: amount (numeric(12,2))
-- Binary: variable, typically 8-12 bytes
-- Text:   "1234567.89" (10 bytes ASCII)
-- Ratio:  roughly equivalent for small values, worse for large
--
-- Column: payload (bytea, 1KB)
-- Binary: 1,024 bytes (raw)
-- Text:   "\x" + 2,048 hex chars = 2,050 bytes
-- Ratio:  2x more bytes on the wire
--
-- Column: id (int8)
-- Binary: 8 bytes (always)
-- Text:   "1234567890" (10 bytes for this value, 1 byte for "1")
-- Ratio:  variable, 0.125x to 2.5x
--
-- For a SELECT returning 1,000 rows with 6 columns
-- (id, name, email, amount, created_at, payload):
--
-- Binary total: ~1.1MB
-- Text total:   ~1.9MB
-- pgx must then parse every text value back to Go types.
-- The parsing overhead: ~2.1ms for 1,000 rows on a 4-core machine.

In binary format (used by all other modes), an int8 is 8 bytes on the wire, always. In text format, the integer 1234567890 is 10 bytes of ASCII characters that pgx must parse back into an int64. The parsing overhead is small for one value, but it scales linearly with result set size.

For timestamp values, the difference is more pronounced: 8 binary bytes versus a 26-30 character text string like 2026-03-05 14:30:00.123456+00. pgx must parse this text string, handling timezone offsets, microsecond precision, and edge cases — work that the binary format avoids entirely because the timestamp arrives as microseconds since the PostgreSQL epoch.

For bytea columns — binary data stored in PostgreSQL — text encoding doubles the payload size because each byte is represented as two hexadecimal characters.

For single-row lookups, the text encoding overhead is negligible — fractions of a millisecond. For bulk operations returning thousands of rows with numeric and timestamp columns, the overhead becomes significant. In my benchmarks, fetching 1,000 rows in SimpleProtocol mode was 45% slower than CacheStatement, compared to 14% slower for Exec mode.

When SimpleProtocol is genuinely the right choice

I have been rather stern about SimpleProtocol's costs. Allow me to be equally honest about its uses.

Scenarios where SimpleProtocol is the only viable option
// When SimpleProtocol is the ONLY option that works:

// 1. CockroachDB compatibility layer
// CockroachDB supports the extended protocol for most queries,
// but some administrative and introspection queries require
// the simple protocol. If you see "unimplemented" errors,
// try SimpleProtocol for those specific queries.

// 2. pgbouncer in statement mode
// Statement mode can reassign backends between individual
// SQL statements. Nothing survives this. SimpleProtocol
// sends self-contained SQL with no server-side state.

// 3. Some test harnesses and mock databases
// Tools like pgmock, pgsql-ast-parser, or custom test
// servers may only implement the simple query protocol.
// SimpleProtocol ensures your queries reach them.

// 4. Queries that legitimately need multiple statements
// The simple protocol can send multiple SQL statements
// in a single message (separated by semicolons).
// The extended protocol cannot — each Parse handles one statement.
// This is rarely useful in application code, but it exists.

Use SimpleProtocol when: nothing else works. Some exotic pooler configurations, legacy proxies, PgBouncer in statement mode, or non-PostgreSQL systems that speak the PostgreSQL protocol only support the simple query protocol. SimpleProtocol is your fallback of last resort. Use it when you must, and upgrade to a better mode as soon as your infrastructure permits.

Per-query overrides: mixing modes in one application

You are not locked into a single mode for your entire application. pgx v5 allows per-query overrides by passing a QueryExecMode as one of the query arguments. This is, in my estimation, the most underused feature in the pgx API.

Per-query mode overrides
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // Set a conservative default on the pool
    // and override per-query when needed.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@localhost:5432/mydb")
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeCacheDescribe // safe default

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Hot-path query: upgrade to CacheStatement for maximum speed
    var id int
    var name, email string
    pool.QueryRow(context.Background(),
        "SELECT id, name, email FROM users WHERE id = $1",
        pgx.QueryExecModeCacheStatement, // override for this query
        42,
    ).Scan(&id, &name, &email)

    // Rare admin query: downgrade to SimpleProtocol for safety
    rows, _ := pool.Query(context.Background(),
        "SELECT tablename FROM pg_tables WHERE schemaname = $1",
        pgx.QueryExecModeSimpleProtocol, // override for this query
        "public",
    )
    defer rows.Close()
    for rows.Next() {
        var tablename string
        rows.Scan(&tablename)
        fmt.Println(tablename)
    }
}

The pattern is straightforward. Set a conservative default on the pool — CacheDescribe is a sound choice for pooled connections — and upgrade specific high-traffic queries to CacheStatement where the plan caching benefit justifies the tighter coupling. Downgrade unusual or one-off queries to Exec or SimpleProtocol when type inference struggles.

This gives you the best of both worlds. Your application's 5 or 10 hot-path queries — the ones executed thousands of times per minute — get full plan caching with CacheStatement. The long tail of administrative, reporting, and migration queries runs safely in CacheDescribe or Exec mode.

A helper pattern for large codebases

For teams managing dozens of services, I find the following pattern useful — not because it adds functionality, but because it makes the mode decision visible and auditable.

Centralizing mode decisions with helper functions
// A helper pattern for large codebases: centralize mode decisions.
// This is not required — it is a convenience for teams that want
// explicit visibility into which queries use which mode.

package db

import "github.com/jackc/pgx/v5"

// HotPath returns CacheStatement mode for high-frequency queries.
// Use only on direct connections or session-mode pooled connections.
func HotPath() pgx.QueryExecMode {
    return pgx.QueryExecModeCacheStatement
}

// Safe returns CacheDescribe mode for general-purpose queries.
// Works behind any pooler in any mode.
func Safe() pgx.QueryExecMode {
    return pgx.QueryExecModeCacheDescribe
}

// Compatible returns Exec mode for one-off or admin queries.
// One round trip, no server state, no type caching.
func Compatible() pgx.QueryExecMode {
    return pgx.QueryExecModeExec
}

// Usage:
// pool.QueryRow(ctx, sql, db.HotPath(), userID).Scan(&name)
// pool.QueryRow(ctx, sql, db.Safe(), userID).Scan(&name)
// pool.QueryRow(ctx, sql, db.Compatible(), userID).Scan(&name)

This is purely organizational. The functions are trivial. But when a new engineer encounters db.HotPath() in a query call, they immediately understand the intent. When someone audits all CacheStatement usage before a pooler migration, they can search for HotPath instead of scanning for mode constants scattered across hundreds of files.

The caveat with per-query CacheStatement

One important caveat: per-query overrides only apply to that specific call. If you use CacheStatement for a query that normally runs in CacheDescribe mode, pgx will prepare and cache the statement on the current backend connection. If the connection is later recycled by a pooler, that cached statement becomes stale — and the next CacheStatement call on a different backend will pay the two-round-trip warmup cost again (or worse, reference a stale handle).

Per-query CacheStatement upgrades should only be used on connections you control — direct connections or session-mode pooled connections. Behind a transaction-mode pooler, upgrading individual queries to CacheStatement defeats the purpose of using CacheDescribe as the default. Use CacheStatement as the pool default (with PgBouncer 1.21+ or Gold Lapel) or not at all.

"PgBouncer has been in production since 2007. In infrastructure years, this makes it approximately ancient — and in infrastructure, ancient is a compliment."

— from You Don't Need Redis, Chapter 17: Sorting Out the Connection Poolers

Benchmarks: the numbers, with context and honesty

I ran these benchmarks on PostgreSQL 16 with pgx v5.7, using a 4-core machine with a local Unix socket connection to eliminate network latency as a variable. Each mode was tested across six query types, 10,000 iterations after a 500-iteration warmup. Numbers represent median latency (p50) for subsequent executions — after any warmup or caching has occurred.

Steady-state latency (local socket, no network overhead)

Query typeCacheStmtCacheDescExecSimple
Simple PK lookup0.38ms0.41ms0.52ms0.55ms
Filtered SELECT (2 cols)0.45ms0.49ms0.61ms0.66ms
INSERT + RETURNING0.51ms0.56ms0.68ms0.73ms
2-table JOIN1.10ms1.22ms1.55ms1.68ms
3-table JOIN + aggregate1.92ms2.10ms2.85ms3.10ms
Bulk SELECT (1000 rows)4.20ms4.35ms4.80ms6.90ms

Several observations worth careful attention.

CacheStatement is consistently fastest, as expected. The plan caching eliminates 0.03-0.18ms per simple query and up to 0.93ms per complex join. The advantage grows with query complexity because more complex plans take longer to generate — a three-table join with aggregation involves more planner decisions than a single-table primary key lookup.

CacheDescribe tracks close behind for simple queries (0.03ms difference on a PK lookup) but falls further behind for complex queries (0.18ms gap on the 3-table join). The gap is the re-planning cost that CacheDescribe pays on every execution. For simple queries, planning is fast and the gap is small. For complex queries, planning is expensive and the gap widens.

Exec and SimpleProtocol are nearly identical for small result sets. The text-encoding overhead of SimpleProtocol only becomes visible when returning substantial data. The 1,000-row bulk SELECT shows a 44% gap (4.80ms vs 6.90ms) between Exec and SimpleProtocol — that 2.1ms difference is entirely text parsing overhead in pgx.

The spread from fastest to slowest is about 45% for simple queries and 64% for bulk operations. For a hot-path query executed 10,000 times per second, the difference between CacheStatement and SimpleProtocol is 1,700ms of cumulative latency per second. That is real capacity.

Detailed benchmark methodology and raw numbers
// Benchmark setup: pgx v5.7, PostgreSQL 16, 1M rows
// 4-core machine, local socket connection (no network latency)
// Each mode tested with 10,000 iterations after 500 warmup
// Query: SELECT id, name, email, created_at FROM users WHERE id = $1

// Results (median latency, p50):
//
// Mode                  First call    Subsequent    Round trips
// -------------------------------------------------------------
// CacheStatement        0.89ms        0.38ms        1 (cached)
// CacheDescribe         0.82ms        0.41ms        1 (cached)
// Exec                  0.52ms        0.52ms        1 (always)
// SimpleProtocol        0.55ms        0.55ms        1 (always)
// CacheStatement (cold) 1.24ms        --            2 (prepare)
//
// For a 3-table JOIN with aggregation:
//
// Mode                  First call    Subsequent    Round trips
// -------------------------------------------------------------
// CacheStatement        3.80ms        1.92ms        1 (cached)
// CacheDescribe         3.65ms        2.10ms        1 (cached)
// Exec                  2.85ms        2.85ms        1 (always)
// SimpleProtocol        3.10ms        3.10ms        1 (always)

How network latency changes the equation

The benchmarks above used a local Unix socket — zero network latency. Most production deployments are not so fortunate. Network latency changes the relative importance of mode selection dramatically.

Benchmark results with simulated network latency
// Same benchmarks, but with 1ms network latency
// (simulated with tc qdisc, representing same-region cloud)
//
// Network latency amplifies round-trip differences:
//
// Mode                  First call    Subsequent    Overhead vs CacheStmt
// -----------------------------------------------------------------------
// CacheStatement        2.89ms        1.38ms        baseline
// CacheDescribe         2.82ms        1.41ms        +2.2%
// Exec                  1.52ms        1.52ms        +10.1%
// SimpleProtocol        1.55ms        1.55ms        +12.3%
//
// At 1ms latency, CacheStatement's cached advantage shrinks
// because the network dominates. But it still wins on subsequent
// calls because it avoids re-planning.
//
// At 5ms latency (cross-region):
//
// CacheStatement        10.89ms       5.38ms        baseline
// CacheDescribe         10.82ms       5.41ms        +0.6%
// Exec                  5.52ms        5.52ms        +2.6%
// SimpleProtocol        5.55ms        5.55ms        +3.2%
//
// The mode choice matters less as network latency dominates.
// At 50ms (cross-continent), all modes are within 1% of each other
// for subsequent calls. The planning overhead is noise.
//
// Conclusion: mode selection matters most for low-latency connections
// (local socket, same-AZ). For high-latency connections, optimize
// the network first.

The key insight: as network latency increases, the mode choice matters less. At 1ms latency (same-region cloud), CacheStatement's advantage over CacheDescribe is 2.2%. At 5ms latency (cross-region), it shrinks to 0.6%. At 50ms latency (cross-continent), all modes are within 1% of each other for subsequent calls.

This does not mean the mode choice is irrelevant for high-latency connections. The first-call cost — two round trips for CacheStatement and CacheDescribe versus one for Exec — is amplified by latency. At 50ms per round trip, the first-call penalty for CacheStatement is an extra 50ms. But for steady-state performance on repeated queries, the planning overhead that separates CacheStatement from CacheDescribe is dwarfed by the network.

If you are optimizing a service that connects across regions, optimize the network first. Move your database closer to your application. Once latency is sub-2ms, mode selection starts to matter.

Tail latency under load

Median latency is a comforting number, but production systems are judged by their tails. Here is how the modes compare at p95, p99, and p99.9 under 100-goroutine concurrency.

Tail latency (p99) under concurrent load
// Tail latency (p99) under load — 100 concurrent goroutines
// pgx v5.7, PostgreSQL 16, pgxpool with MaxConns=50
// Query: SELECT id, name, email FROM users WHERE id = $1
//
// Mode                  p50       p95       p99       p99.9
// -----------------------------------------------------------
// CacheStatement        0.42ms    0.68ms    1.12ms    3.45ms
// CacheDescribe         0.45ms    0.74ms    1.28ms    4.10ms
// Exec                  0.55ms    0.89ms    1.55ms    4.82ms
// SimpleProtocol        0.58ms    0.94ms    1.68ms    5.21ms
//
// The gap widens at tail percentiles. Under contention,
// the extra planning overhead in CacheDescribe/Exec
// holds the connection longer, increasing queue wait
// for other goroutines. CacheStatement's plan reuse
// releases connections faster, reducing tail latency.
//
// At p99.9 the difference between CacheStatement and
// SimpleProtocol is 1.76ms — a 51% increase.
// For SLA-sensitive services targeting p99 < 2ms,
// the mode choice can determine whether you meet your SLA.

The gap widens at tail percentiles. Under contention, the extra planning overhead in CacheDescribe and Exec holds the database connection slightly longer, increasing queue wait for other goroutines. CacheStatement's plan reuse releases connections faster, reducing tail latency.

At p99.9, the difference between CacheStatement and SimpleProtocol is 1.76ms — a 51% increase. For SLA-sensitive services targeting p99 < 2ms, the mode choice can determine whether you meet your SLA or trigger an alert.

An honest note about these benchmarks

I should be forthcoming about what these numbers do not tell you.

These benchmarks measure pgx overhead in isolation — the time spent in the wire protocol, planning, and result encoding. They do not measure application-level performance, which includes business logic, JSON serialization, HTTP handling, and all the other work your service does. If your request handler spends 50ms in application code and 0.5ms in PostgreSQL, the difference between CacheStatement (0.38ms) and SimpleProtocol (0.55ms) is 0.17ms — a 0.3% difference in total request time.

Mode selection matters most when PostgreSQL latency is a significant fraction of your total request time. For database-heavy services — those spending 30-60% of their time in queries — the mode choice has material impact. For services that do significant computation or I/O outside of PostgreSQL, the mode is a rounding error.

A waiter who overstates his case is no waiter at all.

The connection pooler compatibility matrix

This is the section most people arrive at this page looking for. I have seen the search queries. Here is exactly which modes work with which pooler configurations.

Pooler / ConfigCacheStmtCacheDescExecSimpleRecommended
Direct connection (no pooler)YesYesYesYesCacheStatement
PgBouncer session modeYesYesYesYesCacheStatement
PgBouncer transaction mode (< 1.21)NoYesYesYesCacheDescribe
PgBouncer transaction mode (1.21+)Yes *YesYesYesCacheStatement
PgBouncer statement modeNoNoNoYesSimpleProtocol
pgcatNoYesYesYesCacheDescribe
SupavisorNoYesYesYesCacheDescribe
AWS RDS ProxyPartialYesYesYesCacheDescribe
OdysseyNoYesYesYesCacheDescribe
Gold LapelYesYesYesYesCacheStatement

* Requires max_prepared_statements in pgbouncer.ini. Without it, CacheStatement fails intermittently.

Configuring pgx for PgBouncer
package main

import (
    "context"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // Behind PgBouncer in transaction mode (< 1.21):
    // CacheStatement WILL break. Documented in the pgx README.
    // Use CacheDescribe, Exec, or SimpleProtocol.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@pgbouncer:6432/mydb")

    // Option A: CacheDescribe — best balance for PgBouncer
    // Caches type info client-side, uses unnamed statements.
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeCacheDescribe

    // Option B: Exec — simplest, one round trip, no server state
    // config.ConnConfig.DefaultQueryExecMode =
    //     pgx.QueryExecModeExec

    // Option C: SimpleProtocol — last resort, text results
    // config.ConnConfig.DefaultQueryExecMode =
    //     pgx.QueryExecModeSimpleProtocol

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Behind PgBouncer 1.21+ with max_prepared_statements:
    // CacheStatement works again. PgBouncer tracks and re-prepares
    // statements across backend connections.
    // pgbouncer.ini: max_prepared_statements = 200
    //
    // config.ConnConfig.DefaultQueryExecMode =
    //     pgx.QueryExecModeCacheStatement  // safe with 1.21+

    _ = pool
}

PgBouncer in session mode

All five modes work. Session mode assigns a dedicated backend to each client connection, so prepared statements, session variables, and all other per-connection state behave exactly as they would with a direct connection. The trade-off is reduced connection multiplexing — you need as many backend connections as you have clients, which eliminates most of the reason to use a pooler in the first place.

PgBouncer in transaction mode (pre-1.21)

CacheStatement will fail intermittently — and I emphasize "intermittently" because this makes the bug particularly difficult to diagnose. Under light load, PgBouncer may happen to assign you the same backend for consecutive transactions, and CacheStatement works fine. Under heavier load, backends rotate, and the named prepared statements vanish. You get clean tests and flaky production.

Use CacheDescribe (best performance), Exec (simplest), or SimpleProtocol (last resort).

PgBouncer in transaction mode (1.21+)

With max_prepared_statements configured, CacheStatement works again. PgBouncer tracks prepared statements by name across backend connections and transparently re-prepares them when a client lands on a new backend.

pgbouncer.ini — enabling prepared statement support
# pgbouncer.ini — enabling prepared statement support (1.21+)

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction

# The key setting. Without this, CacheStatement breaks.
# Set to at least the number of unique queries your app caches.
# pgx default statement cache: 512 slots.
# If you have 200 unique queries, 256 is a safe value.
max_prepared_statements = 256

# What happens under the hood:
# 1. Client sends Parse("stmtcache_0", "SELECT ...")
# 2. PgBouncer records the mapping: stmtcache_0 -> SQL text
# 3. PgBouncer forwards Parse to the current backend
# 4. If the client later lands on a different backend,
#    PgBouncer re-sends the Parse on that backend before
#    forwarding the Bind/Execute.
# 5. The client never knows the backend changed.

Set max_prepared_statements to at least the number of unique queries your application caches. pgx defaults to 512 slots in the statement cache, so 512 is a safe starting value. If you have reduced StatementCacheCapacity in your pgx config, match that number.

PgBouncer in statement mode

Only SimpleProtocol works reliably. Statement mode is the most aggressive pooling — it can reassign backends between individual statements within a transaction. Neither prepared statements nor Describe caching survives this level of connection shuffling. Multi-statement transactions are not supported in statement mode at all.

If you need statement mode, you need it for a very specific reason (extreme connection multiplexing, typically). Accept the SimpleProtocol overhead or reconsider whether statement mode is truly necessary.

pgcat, Supavisor, Odyssey, and other poolers

Treat them like PgBouncer pre-1.21 in transaction mode unless their documentation specifically states prepared statement support. CacheDescribe is the safest default. Some of these poolers are adding prepared statement tracking — pgcat has experimental support — but I would not rely on experimental features for production query execution.

AWS RDS Proxy

RDS Proxy is a special case. It supports prepared statements in some configurations but has documented limitations around statement count and connection pinning. If a prepared statement causes connection pinning, you lose the multiplexing benefit. CacheDescribe is the safer choice; CacheStatement works but requires careful testing under your specific RDS Proxy configuration.

Debugging prepared statement issues in production

If you have arrived at this section with a production incident in progress, I shall be concise. Here are the diagnostics that matter.

Diagnosing prepared statement issues in production
-- Diagnosing prepared statement issues in production

-- 1. Check what is currently prepared on a backend:
SELECT name, statement, prepare_time, generic_plans, custom_plans
FROM pg_prepared_statements;

-- Expected output with CacheStatement mode:
--  name           | statement                               | generic | custom
-- ----------------+-----------------------------------------+---------+--------
--  stmtcache_0    | SELECT name FROM users WHERE id = $1    |      47 |      5
--  stmtcache_1    | SELECT count(*) FROM orders WHERE ...   |       0 |     52
--  stmtcache_2    | INSERT INTO events (type, data) ...     |      12 |      5

-- 2. Count prepared statements per backend:
SELECT count(*) FROM pg_prepared_statements;
-- If this is near 512 (pgx default), the cache is full
-- and LRU eviction is active. Consider increasing capacity.

-- 3. Detect "statement does not exist" errors in PgBouncer logs:
-- grep "prepared statement" /var/log/pgbouncer/pgbouncer.log
-- If you see these, your pooler is rotating backends
-- and CacheStatement mode is not safe without 1.21+.

-- 4. Monitor statement cache hit rate:
-- (requires pg_stat_statements extension)
SELECT calls, mean_exec_time, query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
-- High-call queries benefit most from CacheStatement.
-- Low-call queries (< 10 per minute) gain little from caching.

Common symptoms and their causes

"prepared statement stmtcache_X does not exist" — Your pooler rotated backends. You are using CacheStatement behind a transaction-mode pooler that does not track prepared statements. Switch to CacheDescribe or upgrade your pooler.

"prepared statement stmtcache_X already exists" — pgx is trying to prepare a statement that already exists on this backend, likely because a different client previously prepared it with the same name. This should not happen with properly configured pgx — it tracks which statements are prepared on which connections. If you see this, check whether you have multiple pgx pools pointing at the same backend through different routes.

"could not determine data type of parameter $1" — You are using Exec mode and PostgreSQL cannot infer the parameter type. Add an explicit cast or switch that query to CacheDescribe mode.

Intermittent query failures that correlate with load — Under low load, your pooler may not rotate backends. Under high load, rotation increases. If failures correlate with traffic spikes, suspect a CacheStatement + transaction-mode-pooler interaction.

Queries that slow down after the first few executions — PostgreSQL may have switched from a custom plan to a generic plan for a cached prepared statement. Check pg_prepared_statements for the generic_plans and custom_plans columns. If generic plans are being used and performance is worse, you may need to adjust the query or use a mode that re-plans each time.

"The database was not slow. It was being asked poorly."

— from You Don't Need Redis, Chapter 3: The ORM Tax

Which mode should you actually use?

I have spent several thousand words explaining the mechanics. Allow me to be direct with the recommendations.

Direct connection (no pooler): keep the default. CacheStatement gives you maximum performance with zero configuration. There is no reason to sacrifice plan caching when your backend connection is stable.

PgBouncer 1.21+ in transaction mode: keep CacheStatement, but configure max_prepared_statements in PgBouncer. Set it to at least your pgx StatementCacheCapacity (default: 512). Verify with a quick test: run your application's full test suite against the pooled connection. If nothing breaks, you are done.

Older PgBouncer or managed pooler: set CacheDescribe as the default. You lose plan caching (8-10% slower for repeated queries) but keep type caching and binary result format. Consider per-query CacheStatement overrides for your hottest queries only if you have a way to ensure those queries always hit the same backend — though in practice, this is hard to guarantee through a transaction-mode pooler.

Serverless or short-lived connections: Exec is the best choice. No warmup cost, one round trip always, and your connections do not live long enough for caching to amortize. Add explicit casts to any queries that produce type inference errors.

Maximum compatibility (exotic poolers, CockroachDB, testing): Exec first. Fall back to SimpleProtocol only if Exec produces type inference errors you cannot resolve with casts.

Mixed workload behind a pooler: CacheDescribe default with per-query CacheStatement overrides for hot paths — but only if those hot paths execute on stable connections. This is the configuration I would recommend for most production Go applications that connect through a pre-1.21 pooler.

The decision I most frequently see teams avoid: upgrading their pooler. If you are running PgBouncer 1.18 in transaction mode and using CacheDescribe as a workaround, upgrading PgBouncer to 1.21+ and switching to CacheStatement is the single most impactful thing you can do. It recovers 8-30% of query latency with a configuration change. No code modifications. No risk. Just a better pooler version.

A word about database/sql and QueryExecMode

If you are using pgx through Go's database/sql interface rather than the native pgx API, QueryExecMode still applies — but the configuration is different. You set it through the connection string DSN, not through the config struct.

The database/sql interface adds a layer of abstraction that affects prepared statement behavior. When you call db.PrepareContext(), database/sql creates a *sql.Stmt that pgx prepares as a named server-side statement, regardless of the default QueryExecMode. When you call db.QueryContext() directly, pgx uses the configured default mode.

If you are using database/sql and experiencing prepared statement errors, check whether your code or a library is calling Prepare() explicitly. ORMs like GORM and sqlx sometimes prepare statements behind your back. The native pgx API gives you full control over execution mode; database/sql gives you compatibility at the cost of some opacity.

My recommendation for new Go services: use the native pgx API. It exposes QueryExecMode directly, supports per-query overrides, and avoids the database/sql abstraction layer's prepared statement quirks. For existing services on database/sql, the migration to native pgx is straightforward and well-documented — see our comparison for a practical guide.

Where Gold Lapel makes the choice irrelevant

I have spent a good number of words explaining five modes, their trade-offs, their pooler compatibility, and their performance characteristics. I should like to now suggest a scenario in which none of it matters.

pgx with Gold Lapel — any mode works
package main

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    // With Gold Lapel: use whatever mode you want.
    // GL manages prepared statement lifecycle at the proxy layer.
    // CacheStatement works because GL tracks cached statements
    // per client session and re-prepares transparently.
    config, _ := pgxpool.ParseConfig(
        "postgres://user:pass@localhost:5433/mydb") // GL's port

    // Use the fastest mode — no compatibility worries
    config.ConnConfig.DefaultQueryExecMode =
        pgx.QueryExecModeCacheStatement

    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Every query benefits from prepared statement caching.
    // GL also tracks which statements are cached on which backends
    // and handles re-preparation transparently if connections rotate.
    // No PgBouncer version checks. No mode compromises.
    var name string
    pool.QueryRow(context.Background(),
        "SELECT name FROM users WHERE id = $1", 42,
    ).Scan(&name)

    fmt.Println(name)
}

Gold Lapel is a PostgreSQL proxy that manages connection pooling, prepared statement lifecycle, and query optimization at the proxy layer. It understands the PostgreSQL wire protocol at the message level — Parse, Bind, Describe, Execute — and handles the session state that transaction-mode poolers discard.

Concretely: when your pgx application sends a Parse message to create a named prepared statement, Gold Lapel receives it, tracks it, and ensures the statement exists on whichever backend your next Execute lands on. If the backend changes, Gold Lapel transparently re-prepares the statement before forwarding your Execute. The client never knows the backend rotated.

This means you can use CacheStatement — the fastest mode — behind Gold Lapel's connection pool without the compatibility concerns that normally force you to downgrade. You get the plan caching. You get the binary results. You get the single-round-trip subsequent calls. And you get connection multiplexing.

Migrating from PgBouncer + CacheDescribe to Gold Lapel + CacheStatement
// Migrating from PgBouncer + CacheDescribe to Gold Lapel + CacheStatement
//
// Before:
//   Connection string: postgres://user:pass@pgbouncer:6432/mydb
//   DefaultQueryExecMode: pgx.QueryExecModeCacheDescribe
//   Per-query overrides: none (unsafe behind PgBouncer)
//   Typical p50 latency for PK lookup: 0.41ms
//
// After:
//   Connection string: postgres://user:pass@goldlapel:5433/mydb
//   DefaultQueryExecMode: pgx.QueryExecModeCacheStatement
//   Per-query overrides: not needed (CacheStatement is safe)
//   Typical p50 latency for PK lookup: 0.38ms
//
// Changes required:
//   1. Update connection string (host and port)
//   2. Change DefaultQueryExecMode to CacheStatement
//      (or remove the setting entirely — it is the pgx default)
//   3. Remove any CacheDescribe/Exec workarounds
//   4. Remove any per-query SimpleProtocol fallbacks
//
// Lines of code changed: 2 (connection string + mode)
// Performance improvement: 8-30% depending on query complexity
// Risk: GL understands the full wire protocol — if pgx sends it,
//        GL handles it. No protocol-level surprises.

If you are currently running CacheDescribe or Exec because your pooler cannot handle prepared statements, switching to Gold Lapel and reverting to CacheStatement would recover that 8-30% of query latency you surrendered. Two lines of code change: the connection string and the mode setting (or remove the mode setting entirely, since CacheStatement is the pgx default).

No per-query overrides. No PgBouncer version checks. No mode compromises.

The best execution mode is the one you do not have to think about.

Frequently asked questions

Terms referenced in this article

If you'll permit me, the question of connection pool sizing — how many connections to maintain, how long to keep them idle, and what happens when the pool is exhausted — is treated in the pgxpool tuning guide. The execution mode determines what happens on each connection; the pool configuration determines how many connections you have. Both deserve attending to.