Pooled vs direct
Transaction pooling is the default. The complete list of what needs the direct endpoint, including the one thing widely believed not to work and does.
Every service has a pooled endpoint and a direct endpoint. They share one hostname and differ only by port, and the lower of your two ports is the pooled one. Both are on your service's Connect tab.
Your ports are your service's own. Services in a location share their hosts' addresses and are told apart by port, so a second service does not get the same pair as the first. They do not change for the life of the service, including when we move it to another host.
Use pooled for application traffic. Use direct for anything that keeps state in a session.
How to choose
- List the things your application does that outlive a single transaction. The table below is the complete set.
- Point those consumers at your direct port, the higher of the two.
- Point everything else at your pooled port, the lower one.
Using both endpoints from one application is normal. A common shape:
- request handling on pooled
- the
LISTENconsumer for your job queue on direct - long-running analytical or maintenance work on direct, so a slow query does not occupy a pooled server connection for minutes
What transaction pooling does
A server connection is assigned to your client for the duration of a transaction and returned to the pool at commit. Between transactions your client does not hold a server connection.
That is the benefit: an application with hundreds of mostly-idle connections occupies server connections only while it is executing. It is also the source of every incompatibility below, all of which have the same shape. Anything scoped to a session has no session to live in.
What works on the pooled endpoint
| Feature | Pooled | Note |
|---|---|---|
| Ordinary queries and transactions | Yes | The case pooling exists for |
| Protocol-level prepared statements | Yes | Supported in transaction mode with a statement cache |
SET LOCAL inside a transaction | Yes | Transaction-scoped |
| Advisory locks taken and released in one transaction | Yes | Transaction-scoped advisory locks are fine |
| Cursors inside a transaction | Yes | Without WITH HOLD |
Prepared statements are worth stating explicitly. Older guidance says they do not work in transaction pooling and a lot of documentation still repeats it. Current PgBouncer supports protocol-level prepared statements in transaction mode when a prepared-statement cache is configured. Documenting them as broken would push you onto the direct endpoint for no reason. Confirm the behaviour against your own workload before you rely on it.
What needs the direct endpoint
- Session-level
SETorRESEToutside a transaction LISTENandNOTIFY- Session-level advisory locks
WITH HOLDcursors- Temporary tables that persist across transactions
SET SESSION AUTHORIZATIONand session-scoped role changes- Session state assumed by some ORMs and job frameworks
The last item is the one that causes support tickets. A framework that sets a session variable at checkout and reads it in a later transaction is relying on session affinity it does not have. It does not error. It reads the default value, and the bug surfaces somewhere else entirely.
Where the pooler runs
One PgBouncer per service, inside that service's own cgroup and under its own user, terminating TLS and forwarding to PostgreSQL over a local Unix socket. Not one pooler per host: that would put several tenants inside one process, make one tenant's connection storm everybody's problem, and give every service on the machine a shared restart to wait for.
The consequence you can see: the pooler's memory and CPU are charged to your plan allocation, and they are included in the configuration profile for your plan rather than pretended away. The consequence you cannot see, and benefit from anyway: a pooler failure affects one service, and no other tenant shares the process.
Sizing your own pool
Size your client pool against your plan's max_connections, which is a hard ceiling set by the
platform and listed per plan on plan limits. Pool sizes on our side
derive from the same figure, set so the pooled path cannot consume every backend slot and leave the
direct endpoint unusable.
Troubleshooting
A session variable reads as its default. The consumer is on the pooled endpoint and is relying on session affinity. Move it to the direct port.
LISTEN never receives anything. Same cause. LISTEN is session-scoped and needs the direct
endpoint.
A WITH HOLD cursor is empty after the transaction commits. Same cause, same fix.
sorry, too many clients already. You have reached max_connections. See
error messages.