Alright, let’s talk about statement mode, the wild child of PgBouncer’s operation modes. If transaction mode is the sensible, predictable friend you bring home to your parents, statement mode is the one who shows up on a motorcycle and might borrow your favorite shirt without asking. It’s incredibly powerful, but you absolutely must understand its quirks, or it will burn you.

In statement mode, PgBouncer doesn’t just hand you a server connection for the duration of your transaction. Oh no, that would be too simple. It hands you a connection for exactly one statement. The moment your SELECT * FROM users or UPDATE accounts SET balance = 0 finishes, poof—that connection is yanked right back into the pool and is immediately available for someone else’s query. This is connection sharing on methamphetamines.

The primary reason you’d subject yourself to this is to achieve an astronomical number of transactions per second (TPS) on a very small number of actual PostgreSQL connections. It squeezes every last drop of utility out of a connection. For massive-scale, simple, and stateless workloads—think a high-traffic website where each HTTP request is just a few quick, independent reads—it can make your hardware sing. The trade-off, as you’ve probably guessed, is that almost anything that involves state is now your enemy.

The Session State Problem (This is the Big One)

PostgreSQL connections maintain session state—settings and data that persist for the life of your connection. In statement mode, since your next query will almost certainly land on a different physical connection, all that state is lost. Let’s run through the nightmare list:

  • Prepared Statements: These are a complete non-starter. You prepare a statement on one connection, and the moment you try to execute it, PgBouncer sends that EXECUTE command to a totally different connection that has no idea your prepared statement ever existed. Kaboom.
-- This will FAIL spectacularly in statement mode.
PREPARE user_fetch (TEXT) AS SELECT * FROM users WHERE username = $1;
EXECUTE user_fetch ('alice');
  • Temporary Tables: Same deal. You CREATE TEMP TABLE my_temp_data on one connection, and any subsequent query trying to use my_temp_data will be routed to a connection that has never heard of it.

  • SET commands: You SET timezone = 'UTC'; and your next query might run in ‘PST’. You SET work_mem = '100MB'; and it’s reset for the next guy. This is chaos.

  • LISTEN/NOTIFY: Notifications might be delivered to a connection that isn’t currently listening, making this whole system utterly unreliable.

  • Transaction Control (BEGIN/COMMIT): This is the most hilarious and dangerous part. You simply cannot run multi-statement transactions. Let’s watch it break:

BEGIN; -- Connection A gets assigned
INSERT INTO logs (message) VALUES ('Transaction started.'); -- Finishes, connection A is returned to the pool
SELECT * FROM some_data; -- This runs on Connection B. It's now auto-committed. Wait, what?
COMMIT; -- This runs on Connection C. Error: there is no transaction in progress.

See the problem? You just committed two independent, auto-committed statements and left a phantom transaction open. This is why you must use a client library that supports transaction pooling via the PIPELINING mode (more on that later) or avoid multi-statement transactions entirely in this mode.

So Why Would Anyone Use This?

Despite the terrifying list above, statement mode has a very specific, glorious use case: autocommit-mode, read-heavy workloads. If your entire application is built on simple SELECT queries and single-statement INSERTs/UPDATEs that are each their own transaction (which is how most modern apps using Object-Relational Mappers like Django or Hibernate behave by default), statement mode is a performance cheat code.

It’s perfect for:

  • Serving most of your website’s front-end reads.
  • High-volume logging of individual events.
  • Simple key-value lookups.

The rule is brutally simple: If your query isn’t 100% self-contained and able to run in its own implicit transaction, statement mode will eat it alive.

The Essential Practice: Parameterized Queries

Because prepared statements are forbidden, you must use parameterized queries sent as a single string to avoid SQL injection vulnerabilities. Good client libraries do this for you. For example, in Python with psycopg2, you’d do:

# This is SAFE and works in statement mode.
# The library handles the parameterization internally.
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

# This is UNSAFE and a terrible idea in any mode.
# It's concatenating strings, inviting SQL injection.
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")

Statement mode is a precision tool, not a daily driver. You deploy it strategically for specific, stateless services in your architecture, not for your entire application. Respect its power, understand its brutal limitations, and it will serve you well. Disrespect it, and it will fail in ways that are subtle, infuriating, and hilarious to anyone who isn’t you.