Connection pooling
How the pooled endpoint actually works, what transaction pooling breaks, and which URL belongs in which slot.
The two endpoints
Every project (and every preview) gets two connection URLs to the same database:
- Direct, port
5432— a normal Postgres session. Everything works; every connection counts fully against the plan's connection budget for as long as it is open. - Pooled, port
6432— CapyDB's managed pooling layer in transaction mode. Your client holds a lightweight connection to the pooler; a real server connection is borrowed for the duration of each transaction and returned the moment it commits or rolls back.
Both terminate TLS and authenticate with the same role and password — rotating credentials updates both at once. The pooled endpoint behaves like PgBouncer in transaction mode, because that is what runs behind it; every documented PgBouncer transaction-pooling caveat applies verbatim.
Why transaction pooling exists
A web app with 50 serverless instances, each holding a 10-connection client pool, wants 500 connections. The plan budget is 15–60 (Limits). Transaction pooling closes that gap: hundreds or thousands of client connections share a small set of real server connections, and since most app transactions take milliseconds, the sharing is invisible — until you use a feature that assumes the server connection is yours.
What breaks on the pooled URL
Anything that stores state on the server connection between transactions can land on a different connection next time:
| Feature | What happens | Fix |
|---|---|---|
Session-level SET (GUCs like search_path, statement_timeout) | Applies to whichever server connection you happened to borrow; silently missing later | Use SET LOCAL inside the transaction, or the direct URL |
| Protocol-level prepared statements | prepared statement "..." already exists / does not exist | Disable driver statement caching (per-driver flags below), or direct URL |
Session-level advisory locks (pg_advisory_lock) | The lock lives on a server connection you no longer hold | Use pg_advisory_xact_lock (transaction-scoped), or direct URL |
LISTEN / NOTIFY | LISTEN registers on a borrowed connection; notifications go nowhere you can hear them | Direct URL, one dedicated listener connection |
| Temporary tables across transactions | Vanish with the borrowed connection | Keep temp-table work inside one transaction, or direct URL |
WITH HOLD cursors, session-scoped pg_export_snapshot | Same story: session state, no session | Direct URL |
Inside a single transaction, everything is normal Postgres — SET LOCAL, transaction-scoped advisory locks, and multi-statement transactions all work.
Pool sizing and what the numbers mean
Current pooling-layer behavior (operational defaults; they can be tuned, and the plan connection budget is always the final cap):
- Up to 20 server connections per database under normal load, with 5 more in reserve when clients are queueing.
- Client connections are cheap — the pooler accepts thousands across the host; you will hit your plan's budget semantics long before the pooler's client ceiling.
- A query that waits more than 120 seconds for a free server connection fails instead of queueing forever. If you see that, the pool is saturated: look for long transactions hogging server connections in Observability.
The plan's connection budget (15 / 30 / 60 for Vibe / Ship / Business) covers direct and pooled access together — the pooler's server-side connections draw from the same budget as your direct sessions. The practical consequence: keep direct connections few and deliberate, and the pooler gets the rest of the budget to multiplex app traffic across.
Which URL for which workload
| Workload | URL | Why |
|---|---|---|
| Serverless functions (Vercel, Lambda, Cloudflare-adjacent runtimes) | Pooled | Each cold start is a new client; pooling is the only thing standing between you and too many connections |
| Web servers and API processes | Pooled | Short transactions, high concurrency — the designed case |
| Schema migrations | Direct | Migration tools rely on advisory locks, session GUCs, and DDL transactions |
Long transactions, batch jobs, COPY-heavy loads | Direct | A transaction holds its server connection the whole time anyway — on the pooled URL it just starves everyone else |
| Queue workers holding one steady connection | Direct | One honest long-lived session beats pretending it is poolable |
psql, GUI clients, pg_dump | Direct | Session features expected throughout |
LISTEN/NOTIFY consumers | Direct | See the table above |
Driver and ORM notes
Each framework guide has the full setup; the pooling-relevant flags in one place:
-
Prisma —
url(pooled) +directUrl(direct) in the datasource, as in the Next.js + Prisma guide. If you still hit prepared-statement errors on the pooled URL, append?pgbouncer=trueto it — that makes Prisma skip protocol-level prepared statements andDEALLOCATE ALLassumptions. -
postgres.js (standalone or under Drizzle) —
postgres(url, { prepare: false })on the pooled URL. -
node-postgres (
pg) — works as-is; it uses the extended protocol per query without named server-side statements. A modest client-sidepg.Poolon top of the pooled URL is fine — see the Node guide. -
pgx (Go) — default statement caching breaks behind the pooler; set
default_query_exec_mode=simple_protocolon the pooled URL (Go guide). -
asyncpg / SQLAlchemy async —
statement_cache_size=0on the pooled URL (SQLAlchemy guide). -
SQLAlchemy in serverless — use
poolclass=NullPoolso SQLAlchemy opens one connection per unit of work and lets the server-side pooler do the actual pooling; a client-side pool inside a short-lived function is dead weight that pins connections:from sqlalchemy import create_engine from sqlalchemy.pool import NullPool engine = create_engine(os.environ["DATABASE_URL"], poolclass=NullPool)In a long-lived server process, the small client pool from the SQLAlchemy guide is the better default.
-
Rails —
prepared_statements: falseon the pooled URL; migrations on the direct URL because Rails serializes them with advisory locks (Rails guide). -
Django —
conn_max_age=0on the pooled URL; persistent Django connections belong on the direct URL (Django guide).
Symptoms decoder
prepared statement "..." already exists— statement-caching driver on the pooled URL; flags above.too many connections— budget exhausted; move app traffic to pooled, shrink client pools, hunt idle direct sessions in Observability.- Queries randomly missing a
SETyou swear you ran — session GUC on a borrowed connection;SET LOCALor direct. query_wait_timeout/ queries failing after ~2 minutes of waiting — pool saturation; find the long transactions.
More error-message-first triage in Troubleshooting.