Cloud Tech by Victor
DatabasesIntermediate

Database Connection Pooling

Why transaction pooling gives far better connection reuse than session pooling, and why that same efficiency silently breaks SQL PREPARE, SET, LISTEN, and session-level advisory locks, even though PgBouncer can support protocol-level named prepared statements under transaction pooling when max_prepared_statements is enabled.

Updated 2026-07-243 min read

Overview

A connection pooler sits between an application and the database, reusing a small, fixed set of real server connections across many more client connections, which matters because opening a real database connection has real overhead and most databases have a hard connection limit. Session pooling is the most conservative mode, one server connection stays assigned to a client for their entire session, supporting every PostgreSQL feature but scaling only as far as the connection limit directly allows. Transaction pooling reassigns the server connection at every transaction boundary instead, dramatically improving connection reuse for applications with many short-lived connections, but it explicitly breaks session-level features, prepared statements, SET/RESET, LISTEN, and session-level advisory locks among them, because none of those can rely on landing on the same server connection twice.

Quick Reference

Pooling modeConnection assigned forSupports all features?
Session poolingThe entire client sessionYes
Transaction poolingA single transactionNo, breaks session-level features
Statement poolingA single statement (autocommit only)No, also disallows multi-statement transactions
Feature broken by transaction poolingWhy
SQL PREPARE/DEALLOCATEStatement lives on one specific server connection (protocol-level named prepared statements can work under transaction pooling if max_prepared_statements is non-zero)
SET/RESETSession-level configuration tied to one connection
LISTENNotification subscription tied to one persistent connection
Session-level advisory locksLock is held by a specific connection, not the client

Syntax

ini
; pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Examples

sql
-- Breaks under transaction pooling: the PREPARE may land on a
-- different server connection than the later EXECUTE.
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
-- ... later, possibly on a different pooled connection ...
EXECUTE get_user(42);
-- Error: prepared statement "get_user" does not exist

Transaction pooling breaks more than people expect

Prepared statements, SET/RESET, LISTEN, WITH HOLD cursors, and session-level advisory locks all depend on state tied to one specific server connection. Transaction pooling's efficiency comes precisely from not guaranteeing that, audit for these features before switching pool modes.

Visual Diagram

Common Mistakes

  • Switching to transaction pooling for better scalability without auditing the application for prepared statements, LISTEN, or session-level advisory locks first.
  • Assuming pooling mode is purely a performance knob with no functional consequences, when transaction and statement pooling both explicitly remove PostgreSQL feature support.
  • Sizing the pool to match expected concurrent clients instead of expected concurrent transactions, overprovisioning real server connections against session pooling, or underprovisioning against a database's actual connection limit.
  • Choosing statement pooling without realizing it disallows multi-statement transactions entirely, a much stronger constraint than transaction pooling.

Performance

  • Real database connections have meaningful setup cost and are capped by a hard server-side limit; pooling directly reduces both the cost and the count needed for a given level of application concurrency.
  • Transaction pooling supports substantially higher client concurrency per real server connection than session pooling, precisely because idle-between-transactions clients hold no server connection at all.

Best Practices

  • Choose transaction pooling by default for typical web application workloads with many short-lived connections, provided the application doesn't depend on session-level features.
  • Audit for PREPARE, SET/RESET, LISTEN, and session-level advisory locks before adopting transaction or statement pooling, and refactor around them or fall back to session pooling if they're required.
  • Size the pool based on expected concurrent transactions (for transaction pooling) or concurrent sessions (for session pooling), not a single blanket number.
  • Treat pooling mode as a feature-compatibility decision as much as a performance one, not purely a scalability dial.

Interview questions

What is the practical difference between session pooling and transaction pooling, and why does transaction pooling scale better?

Session pooling assigns one server connection to a client for their entire session, released back to the pool only when the client disconnects, which supports every PostgreSQL feature but means a mostly-idle client still occupies a real server connection the whole time it's connected. Transaction pooling instead assigns a server connection only for the duration of a single transaction, returning it to the pool the moment the transaction ends, so many more clients can share a small, fixed pool of real connections, since a client that isn't actively mid-transaction isn't holding one at all. This is why transaction pooling is the standard choice for applications with many short-lived connections (like a web app's connection-per-request pattern) against a database with a hard connection limit.

An application uses PREPARE to create a reusable prepared statement, then relies on it across multiple requests. What happens if it's deployed behind a transaction-pooled PgBouncer, and why?

It breaks. A SQL PREPARE statement is a session-level feature, it lives on whatever specific server connection issued the PREPARE, but transaction pooling reassigns the underlying server connection to a different client (or the same client's next transaction) as soon as each transaction ends, so there's no guarantee a later request lands on that same server connection where the prepared statement actually exists. This is explicitly documented as one of the session-based features transaction pooling breaks, along with SET/RESET, LISTEN, WITH HOLD cursors, and session-level advisory locks, all of which depend on state tied to one specific, persistent server connection. This is distinct from protocol-level named prepared statements issued via the extended query protocol (what most driver-level "prepared statements" actually are), which PgBouncer can support under transaction pooling when `max_prepared_statements` is set to a non-zero value.

Why would you choose session pooling over transaction pooling even though it scales to fewer concurrent clients per server connection?

Session pooling is the only mode of the two that supports every PostgreSQL feature without exception, prepared statements, session variables set via SET, LISTEN/NOTIFY, session-level advisory locks, because the server connection genuinely stays with the client for as long as their session lasts. If an application depends on any of those session-level features and can't be refactored around them, session pooling is the correct choice despite its lower connection-reuse efficiency, trading raw scalability for full feature compatibility rather than working around broken session state.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement