# PgBouncer Pooling: Port 5432 vs 6432 Explained

> How PgBouncer pools PostgreSQL connections, why the direct 5432 and pooled 6432 ports differ, the three pooling modes, and what breaks in transaction mode.

1. [Home](/)
2. [Blog](/blog)
3. Connection Pooling with PgBouncer: What Ports 5432 and 6432 Really Do

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.

## The short version

PostgreSQL connections are processes behind a hard ceiling, so a pooler reuses a small warm set instead of opening one per request. Ports 5432 and 6432 are different contracts: 6432 for high-concurrency app traffic in transaction mode, 5432 for anything needing session state — migrations, LISTEN/NOTIFY, advisory locks, prepared statements you cannot disable.

[Teo Marquardt](/about#author)

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](/blog/postgres-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](https://www.postgresql.org/docs/current/runtime-config-connection.html) 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](/blog/postgres-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](https://www.pgbouncer.org/) 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?

| Mode        | Server connection is held for                  | Good for                                               | What you give up                                                                  |
| ----------- | ---------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Session     | The whole client session, until it disconnects | Long-lived connections that need full session features | Most of the pooling benefit — it behaves close to a direct connection             |
| Transaction | A single transaction, then it's returned       | Web and API traffic with short transactions            | Anything spanning transactions: session state, prepared statements, LISTEN/NOTIFY |
| Statement   | A single statement                             | Very high concurrency, autocommit-only workloads       | Multi-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.

| Task                                   | Port | Why                                                                               |
| -------------------------------------- | ---- | --------------------------------------------------------------------------------- |
| Regular app request traffic            | 6432 | Short transactions at high concurrency — exactly what transaction pooling is for  |
| Database migrations and schema changes | 5432 | Often need session state, advisory locks, or steps that run outside a transaction |
| LISTEN / NOTIFY                        | 5432 | Needs a persistent session that transaction pooling won't keep                    |
| Session advisory locks                 | 5432 | A session lock has to be released by the same session that took it                |
| A psql session for debugging           | 5432 | You 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](https://www.pgbouncer.org/config.html#max%5Fprepared%5Fstatements) 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](https://docs.runsite.app) 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](/blog/self-hosted-vs-managed-postgresql). [Managed PostgreSQL](/services/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 German 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](/blog/managed-postgresql-for-developers), 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. A cache in front of the database changes how many of those connections you need, and [the caching pattern you pick decides whether it changes them on reads, on writes, or on both](/blog/cache-aside-write-through-write-behind). 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. A pooler makes the connections you have go further; the other way to get further is to stop issuing the query at all, which is what [a cache in front of the database](/blog/when-to-use-redis-cache) does for reads that repeat.

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

Related service

## Managed PostgreSQL on Runsite

Deploy from a single git push and keep active apps warm — no cold starts, hosted entirely in the EU with a signed GDPR DPA on every plan.

[Explore Managed PostgreSQL](/services/postgresql)

[Back to all articles](/blog)

FAQ

## Frequently Asked Questions

Common questions about this service.

### What's the difference between port 5432 and 6432 on PostgreSQL?

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.

### What is transaction pooling in PgBouncer?

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.

### Why do prepared statements break with PgBouncer?

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.

### Do I still need an app-side connection pool if I use PgBouncer?

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.

Keep reading

## Related articles

[Databases10 min readPostgreSQL "FATAL: sorry, too many clients already" — What It Means and How to Fix ItYour app is suddenly throwing 500s and the database logs are full of "too many clients already." Here's what the error really means, how to see what's hogging your connections, and the fix that actually holds: pooling, not a bigger number.Jun 26, 2026Read](/blog/postgres-too-many-clients-already)[Databases11 min readSelf-Hosted vs Managed PostgreSQL: The Real Cost of OwnershipThe €5 VPS looks cheaper than the €14 managed plan — until you price the hours. A line-by-line cost of ownership for self-hosting Postgres versus managed, the risk nobody puts on the invoice, and when self-hosting actually wins.Jul 27, 2026Read](/blog/self-hosted-vs-managed-postgresql)[Databases11 min readManaged PostgreSQL for Developers: What the Host Actually Takes Off Your PlateInstalling Postgres takes a minute. Running it in production for three years is the real job. Here's the specific list of operational chores that move to the host when a database is "managed" — and the ones that stay yours no matter what.Jul 17, 2026Read](/blog/managed-postgresql-for-developers)

## Your app deserves to be online

€5 of credit on signup. Deploy in under a minute. No credit card needed.

[Start deploying](https://dashboard.runsite.app/login)[View documentation](https://docs.runsite.app)

---

Source: https://runsite.app/blog/pgbouncer-connection-pooling
