When you’re building applications that talk to PostgreSQL, you’ll eventually hit a wall: database connections are expensive. Each connection consumes memory and resources on your database server, and there’s a hard limit to how many you can have. This is where connection poolers like PgBouncer come in, and understanding how they work can make the difference between an application that scales smoothly and one that grinds to a halt under load.
The Connection Problem
Let’s start with the basics. Every time your application connects to PostgreSQL, the database server needs to fork a new process. This process consumes memory—typically around 10MB per connection, though this can vary. If you have 100 connections, that’s roughly 1GB of RAM just for maintaining those connections, before you even start running queries.
For small applications, this isn’t a problem. But as your user base grows, those connections add up fast. A typical PostgreSQL installation might support a few hundred connections at most before performance degrades. Meanwhile, your web application might need to handle thousands of concurrent users.
Connection poolers solve this by acting as a middleman. Instead of each application instance connecting directly to PostgreSQL, they connect to the pooler. The pooler then maintains a smaller number of connections to the actual database and shares them among all the clients. The question is: how should those connections be shared?
Session Mode: The Traditional Approach
Session mode works the way you’d expect if you’re familiar with direct database connections. When a client connects to PgBouncer in session mode, they get assigned a dedicated PostgreSQL connection that stays with them for their entire session. The connection is only released when the client explicitly disconnects.
This feels natural because it’s how most developers think about database connections. You connect once, run a bunch of queries over time, maybe set some session variables, and then disconnect when you’re done. Everything that depends on session state—like temporary tables, prepared statements, or SET commands—works exactly as it would with a direct connection.
The downside is obvious: session mode doesn’t actually reduce the number of database connections you need. If you have 500 active clients, you need 500 PostgreSQL connections. The pooler is still doing useful work (it handles connection overhead on the client side and can route to different backends), but it’s not solving the fundamental scaling problem.
Session mode makes sense when:
- Your application relies on session-level features like temporary tables or advisory locks
- You need to use prepared statements that persist across multiple queries
- You’re migrating from direct connections and want minimal changes to your application
- Your connection count is manageable and within PostgreSQL’s limits
Think of it as “connection routing” rather than true connection pooling. It’s a stepping stone that gives you better connection management without requiring changes to how your application uses the database.
Transaction Mode: Aggressive Connection Sharing
Transaction mode takes a different approach. Instead of assigning connections for entire sessions, it only assigns a connection when a transaction is active. The moment your application commits or rolls back a transaction, the connection gets returned to the pool and can be immediately reused by another client.
This is where connection pooling really shines. A pool of just 20 database connections can serve thousands of clients, as long as those clients aren’t all running transactions simultaneously. Think about a typical web application: users spend most of their time reading pages, clicking buttons, and thinking. The actual database work happens in short bursts—fetch some data, update a record, commit, done. Between those bursts, the connection sits idle.
Transaction mode exploits this pattern ruthlessly. During those idle periods, your connection isn’t sitting around waiting for you—it’s helping someone else. The pooler becomes a traffic controller, efficiently routing database work through a small number of connections.
Here’s a practical example. Imagine you’re running an e-commerce site with 10,000 concurrent users browsing products. Most of them are just looking at pages, which requires quick read queries. Maybe 1% are actively checking out at any given moment, which requires transactions. With session mode, you’d need 10,000 database connections. With transaction mode, you might only need 50-100 connections to handle the actual load comfortably.
The catch is that transaction mode breaks anything that depends on session state. You can’t use temporary tables because the next query might get a different connection. Prepared statements need to be re-prepared on each connection. Session-level settings get reset. Advisory locks don’t work across the pool.
Transaction mode is ideal when:
- You have many more clients than you have database connections available
- Your application mostly runs short, discrete transactions
- You don’t rely on session-level PostgreSQL features
- You’re willing to adjust your application code to work within the constraints
How They Actually Differ in Practice
The difference between these modes becomes clear when you watch them under load. Let’s say you’re running a pool with 10 database connections.
In session mode, the first 10 clients to connect get those connections and keep them. Client #11 has to wait until someone disconnects. If those first 10 clients are long-running sessions that occasionally run queries, you’re wasting most of your connection capacity.
In transaction mode, all 10 connections are actively working most of the time. Client #11 doesn’t need to wait for someone to disconnect—they just need to wait for someone to finish their transaction. If transactions are short (as they should be), wait times are minimal even under heavy load.
The performance difference can be dramatic. In high-concurrency scenarios, transaction mode often handles 10x or more clients than session mode with the same number of database connections. But this comes at the cost of compatibility—your application needs to be designed to work with it.
Connection Lifecycle Differences
Understanding what happens to a connection in each mode helps clarify when to use which:
Session Mode Timeline:
- Client connects to PgBouncer
- PgBouncer assigns a PostgreSQL connection from the pool
- Client runs queries, sets variables, creates temp tables, whatever they need
- Connection stays assigned through idle periods
- Client disconnects
- Connection returns to pool
Transaction Mode Timeline:
- Client connects to PgBouncer (gets a “virtual” connection, no PostgreSQL connection yet)
- Client sends
BEGINor starts an implicit transaction - PgBouncer assigns a PostgreSQL connection from the pool
- Client runs queries
- Client sends
COMMITorROLLBACK - Connection immediately returns to pool
- Client is still connected to PgBouncer but has no PostgreSQL connection
- Repeat from step 2 for next transaction
This explains why session variables don’t work in transaction mode—they’re getting set on a connection that might be serving a different client seconds later.
Making the Right Choice
Start by asking yourself: what’s my constraint?
If your limitation is the number of database connections, and you have more clients than connections, transaction mode is probably your answer. You’ll need to audit your code for session-dependent features and refactor them, but the scalability gains are worth it.
If you’re hitting connection limits but your application is heavily dependent on session features, you might need to optimize elsewhere first. Look at reducing connection lifetime, implementing better connection cleanup, or even scaling your database vertically to support more connections.
For new applications, consider designing with transaction mode in mind from the start. Keep transactions short and discrete. Avoid session state. Use connection-level prepared statements (which PgBouncer handles differently) or just send regular queries. This discipline pays off in scalability even if you start with session mode.
Some applications need both. You can run PgBouncer with different pools in different modes—use transaction mode for your web application’s high-volume, short-transaction workload, and session mode for administrative scripts or background jobs that need session features.
Common Pitfalls and How to Avoid Them
Pitfall 1: Long transactions in transaction mode If your transactions take seconds or minutes to complete, you’re defeating the purpose of transaction mode. Those connections can’t be shared while they’re locked in a long transaction. Keep transactions short and focused.
Pitfall 2: Not handling connection errors gracefully In transaction mode, you might get a different backend connection for each transaction. Some application frameworks assume connection stability and cache metadata about the connection. Make sure your error handling doesn’t make these assumptions.
Pitfall 3: Using prepared statements incorrectly Standard prepared statements don’t work in transaction mode because they’re session-scoped. PgBouncer offers a workaround with statement-level pooling, but it has its own complexities. Often, it’s simpler to just not use prepared statements or use parameterized queries instead.
Pitfall 4: Assuming session mode is “safe” Session mode still has connection limits. Just because it works like a direct connection doesn’t mean you can treat it carelessly. You still need proper connection management, timeouts, and cleanup.
Monitoring and Tuning
Regardless of which mode you choose, monitor your connection pool metrics:
- Pool size utilization: How many connections are actually in use?
- Queue depth: How many clients are waiting for connections?
- Average transaction time: Are transactions completing quickly?
- Connection churn: How often are connections being assigned and released?
For session mode, watch for clients holding connections idle for long periods. These are opportunities for optimization—either shorten the session lifetime or consider switching to transaction mode.
For transaction mode, watch transaction duration. Long transactions create bottlenecks. Also monitor for errors related to connection sharing, which might indicate you’re using session features that don’t work in transaction mode.
Wrapping Up
Connection modes aren’t about one being better than the other—they’re tools for different situations. Session mode gives you compatibility and simplicity at the cost of scalability. Transaction mode gives you massive scalability improvements at the cost of compatibility and some additional complexity.
Most modern web applications benefit from transaction mode because their workload pattern—many clients, short transactions—is exactly what it’s optimized for. But session mode has its place for applications that need session features or as a stepping stone when migrating to pooled connections.
The key is understanding what each mode does, how it handles connections, and what constraints it imposes. With that knowledge, you can make an informed choice that matches your application’s needs and scale characteristics.
Citations
- [1] https://docs.digitalocean.com/products/databases/postgresql/how-to/manage-connection-pools/
- [2] https://docs.selectel.ru/en/cloud/managed-databases/postgresql/connection-pooler/
- [3] https://jpcamara.com/2023/04/12/pgbouncer-is-useful.html
- [4] https://www.cybertec-postgresql.com/en/pgbouncer-types-of-postgresql-connection-pooling/
- [5] https://linuxpolska.com/en/knowledge-base/blog/pgbouncer-light-connection-pooler-for-postgresql/