DatabasesJuly 20, 202610 min read

Connection Pooling with PgBouncer: What Ports 5432 and 6432 Really Do

You switched to port 6432 to stop the connection errors, and now prepared statements throw and SET doesn't stick. That's pooling working as designed. Here's what a pooler actually does, why 5432 and 6432 are different contracts, and what breaks in transaction mode.

RThe Runsite Team

You added `?pgbouncer=true` to your connection string, moved the port from 5432 to 6432, and the `too many clients already` errors stopped. Then something else broke: a prepared statement throws an error it never used to, or a `SET search_path` you rely on quietly stops taking effect. Nothing is actually wrong. That's connection pooling doing exactly what it's designed to do, and the surprises come from switching it on without knowing what it changes underneath.

This is the concept behind the fix. If you're staring at the error itself right now, the walkthrough in too many clients already is the fire extinguisher. This piece is the other half: what a pooler actually does, why the pooled 6432 port and the direct 5432 port are two different contracts rather than two spellings of the same thing, and which of your session habits stop working once traffic runs through PgBouncer.

Why Postgres needs a pooler at all

PostgreSQL uses a process-per-connection model. Every open connection is a real operating-system process on the server with its own memory for query plans, buffers, and a work area, held whether the connection is running a query or sitting idle. That's a sturdy design, and it's also why the `max_connections` limit exists: a few hundred mostly-idle connections can eat the RAM the database needs to actually run queries.

Opening a connection isn't free either. There's a TCP handshake, a TLS negotiation, authentication, and backend startup on every one, so an app that opens a fresh connection per request pays that tax thousands of times a minute for work a handful of reused connections could do. A pooler exists to solve both problems at once: keep a small, warm set of real connections open and let many clients take turns on them instead of each dialing its own.

Two layers of pooling, and why you may need both

Pooling happens at two levels, and they cover different halves of the problem.

The first is the pool inside your application, shipped with every serious database driver. It keeps a few connections open per process and hands them to requests instead of dialing a new one each time. This is the first thing to reach for, and too many clients already covers how to size it. It has one hard limit: an app-side pool only knows about its own process. Run four instances that each cap at 20 connections and you've authorized 80, plus whatever your workers and cron jobs open, with nothing coordinating that total against the database ceiling.

The second level is an external pooler that sits in front of the database, with every client connecting through it. Because all traffic funnels through one place, it can enforce a fleet-wide limit no app-side pool can see. This is the layer that earns its keep when you scale horizontally or run serverless functions, where the instance count is elastic and each cold start would otherwise open its own connections. PgBouncer is the standard tool for the job, and it's what the 6432 port is.

What PgBouncer actually does

PgBouncer is a lightweight proxy that speaks the PostgreSQL wire protocol. Your app connects to PgBouncer as if it were the database; PgBouncer keeps its own small set of real connections to Postgres and multiplexes incoming clients onto them. A thousand clients can be "connected" to PgBouncer while only, say, twenty real backend connections exist behind it. It works because most connected clients aren't doing anything at any given instant, so a small shared set is enough to serve them without each one holding a backend process hostage.

How aggressively that sharing happens comes down to one setting: the pooling mode.

The three pooling modes: session vs transaction vs statement

PgBouncer offers three modes, and they differ on a single question: how long does a client get to keep a real connection before it goes back into the shared set?

ModeServer connection is held forGood forWhat you give up
SessionThe whole client session, until it disconnectsLong-lived connections that need full session featuresMost of the pooling benefit — it behaves close to a direct connection
TransactionA single transaction, then it's returnedWeb and API traffic with short transactionsAnything spanning transactions: session state, prepared statements, LISTEN/NOTIFY
StatementA single statementVery high concurrency, autocommit-only workloadsMulti-statement transactions, which it forbids outright
The three PgBouncer pooling modes. Transaction mode is the usual choice for web apps, and it's also the one with the footguns.

In session pooling, a client holds its assigned backend connection from connect to disconnect. None of your session behaviour changes, which makes it the safest mode, but it also means one idle client still ties up one real connection. You get the connection reuse without much of the multiplexing.

Transaction pooling is where the benefit lives, and it's the mode most web apps want. A client is handed a real connection only for the duration of a transaction; the instant that transaction commits or rolls back, the connection goes back to the pool for someone else. Between transactions your client is connected to PgBouncer but holds no backend at all. This is how a handful of real connections serve a large fleet, and it's almost certainly what a managed `pgbouncer=true` endpoint gives you.

Statement pooling goes one step further and reclaims the connection after every single statement, which rules out multi-statement transactions entirely. It's a niche mode for specific autocommit workloads, not something you'd turn on by accident.

Port 5432 vs 6432: two doors, different contracts

Here's the mental model that makes the rest click. Port 5432 and port 6432 are not two databases, and not two URLs for convenience. They're two doors into the same database, and each door comes with a different contract about what you're allowed to assume.

Port 5432 is the direct line to Postgres. You get a real backend process to yourself for the whole session, so every session-level feature works the way the manual says. Port 6432 goes through PgBouncer, typically in transaction mode, so you get a connection only for each transaction and must not assume anything survives between them. Same data, same queries, different rules about session state.

TaskPortWhy
Regular app request traffic6432Short transactions at high concurrency — exactly what transaction pooling is for
Database migrations and schema changes5432Often need session state, advisory locks, or steps that run outside a transaction
LISTEN / NOTIFY5432Needs a persistent session that transaction pooling won't keep
Session advisory locks5432A session lock has to be released by the same session that took it
A psql session for debugging5432You want a stable session, not a connection that changes under you
A rough division of labour: 6432 for app traffic, 5432 for the occasional job that needs a stable session.

What transaction pooling breaks (and how to fix it)

Every gotcha in transaction pooling traces back to one root cause: your client can get a different backend connection for the next transaction, so anything that expected to live on one connection across transactions won't be there. These are the ones that bite in practice.

  • Prepared statements. Many drivers prepare a statement once and reuse the plan, which assumes the same backend is still there next time. Under transaction pooling it may not be. As of writing, recent PgBouncer versions (1.21 and later) can track prepared statements across the pool when configured, but plenty of managed setups still expect you to disable client-side prepared-statement caching. Each driver has a knob for it — Prisma has `pgbouncer=true`, asyncpg has `statement_cache_size=0`, the JDBC driver has `prepareThreshold=0` — and the exact setting for yours belongs in the Runsite docs rather than guessed at mid-incident.
  • Session-level `SET`. Running `SET search_path = ...` or `SET timezone = ...` on its own sets state on whatever backend you happened to hold, and the next transaction may land elsewhere without it. Use `SET LOCAL` inside the transaction that needs it, so the setting is scoped to that transaction and travels with the work.
  • `LISTEN` / `NOTIFY`. These depend on a connection that stays put and keeps listening. Transaction pooling recycles the connection out from under them, so run pub/sub-style listeners on the direct 5432 port.
  • Session advisory locks. `pg_advisory_lock()` takes a lock tied to the session and expects the same session to release it. Through a transaction pooler the session isn't stable, so the lock can outlive the client that took it and wedge everyone else. Use the transaction-scoped variant `pg_advisory_xact_lock()`, or take these on 5432.
  • Temporary tables and `WITH HOLD` cursors. Both are session-scoped and won't reliably survive to the next transaction on a pooled connection. If you depend on them, put that work on a direct session.

The rule that covers all of them

Transaction pooling only promises you a connection for the length of a transaction. If a feature needs state to persist between transactions on the same physical connection, it either wants a LOCAL or transaction-scoped variant, or it wants the direct 5432 port. When in doubt, ask one question: does this assume the same connection is still here next time?

Sizing the pool: smaller than you'd guess

The instinct with pools is to make them big, on the theory that more connections means more throughput. Past a point it's the reverse. Every active backend is a process competing for the same cores and disk, so a pool far larger than the machine can run in parallel just adds contention and context-switching, and queries slow down under load instead of speeding up.

A better starting point is a small pool sized to the database's actual CPU and disk, not to your traffic. A pooler in transaction mode keeps those few connections busy by cycling them between transactions quickly, which is the whole idea: you don't need one backend per client, you need enough to keep the CPU saturated and no more. Start conservative, watch for clients spending time waiting on a free connection, and raise the size only if the database has the headroom to use them. A big number on a small instance buys instability, the same trap as raising `max_connections` to paper over a leak.

How Runsite handles it

Running PgBouncer yourself means another process to deploy, configure, and keep alive next to the database, and it's one line on the real cost of self-hosting Postgres. Managed PostgreSQL on Runsite has PgBouncer built in, so there's no separate pooler to operate. The direct database is on port 5432 and the pooled endpoint is on 6432, chosen by a single flag on the connection string:

bash
# Direct session — every session feature works (port 5432)
DATABASE_URL=postgresql://user:pass@db.runsite.app:5432/mydb

# Pooled through PgBouncer — for app request traffic (port 6432)
DATABASE_URL=postgresql://user:pass@db.runsite.app:6432/mydb?pgbouncer=true

The usual pattern is to point your application's normal traffic at 6432 and keep 5432 for migrations, admin tasks, and anything that needs a stable session. Because the platform is EU-native, the database, its WAL archive, and every backup stay in a Frankfurt, Germany region with a signed GDPR DPA on every plan, so reaching for the pooled endpoint doesn't move any of your data — it's the same database behind both ports. Pooling is one of the operational jobs that managed PostgreSQL is meant to take off your plate, and this is what that looks like in practice.

The short version

A pooler exists because PostgreSQL connections are expensive processes behind a hard ceiling, and reusing a small warm set beats opening a new one per request. PgBouncer does that in one of three modes, and transaction mode — the common one for web apps — hands you a real connection only for each transaction. That's why the pooled 6432 port and the direct 5432 port are different contracts: 6432 for high-concurrency app traffic, 5432 for anything that needs session state, whether that's a migration, `LISTEN/NOTIFY`, a session advisory lock, or prepared statements you can't disable. Match the port to the work and pooling stops being a source of mystery errors.

You shouldn't have to run the pooler to get its benefit. Spin up a managed PostgreSQL database with PgBouncer already wired to the 6432 endpoint, and point your app at pooling that someone else keeps alive.

FAQ

Frequently Asked Questions

Common questions about this service.

Port 5432 is the standard direct connection to PostgreSQL — you get a real backend process for your whole session, and every session-level feature works normally. Port 6432 is the common convention for a pooled endpoint through PgBouncer, usually in transaction mode, where you're handed a real connection only for the length of each transaction. Both point at the same database. Use 6432 for high-concurrency application traffic and 5432 for migrations, admin tasks, and anything that relies on session state like LISTEN/NOTIFY or session advisory locks.

Transaction pooling is the PgBouncer mode where a client is assigned a real database connection only for the length of a single transaction. As soon as that transaction commits or rolls back, the connection returns to the shared pool for another client. It's the mode most web apps use, because it lets a small set of real connections serve a large number of clients. The tradeoff is that anything relying on session state persisting between transactions — prepared statements, session-level SET, LISTEN/NOTIFY — can't be assumed to work without adjustment.

Many drivers prepare a statement on one connection and reuse it, which assumes that connection is still assigned to them next time. Under transaction pooling it usually isn't — the next transaction may land on a different backend that never saw the prepared statement. As of writing, recent PgBouncer versions (1.21 and later) can track prepared statements across the pool when configured, but many setups still expect you to disable client-side prepared-statement caching. Each driver has a setting for it, so check your driver's docs for the exact flag.

Usually yes, because they do different jobs. An app-side pool avoids the cost of opening a new connection for every request within a single process. PgBouncer enforces a limit across your whole fleet — every instance, worker, and cron job — which an app-side pool can't see because it only knows its own process. For a horizontally scaled or serverless app, you want the driver pool for per-process reuse and PgBouncer in front to keep the total under the database's ceiling.

Your app deserves to be online

Free to start. Deploy in under a minute. No credit card needed.