all posts

How to connect to Postgres with a connection pooler

Ajay Kumar··8 min read

The error arrives the same way for everyone: FATAL: sorry, too many clients already. It shows up under load, it goes away when the load does, and the first instinct is to raise max_connections. That instinct is wrong often enough that it's worth understanding why before you touch the setting.

Postgres uses a process-per-connection model. Every client connection forks a backend process with its own memory — work_mem, temp buffers, catalog caches, the lot. A few hundred of those is not a number the planner cares about; it's a number your host's memory cares about. So an idle connection is not free, and 500 mostly-idle connections can cost more RAM than the working set you actually wanted cached.

Now put a serverless runtime in front of it. Each concurrent invocation is a fresh process with a fresh client, so a burst of 300 requests opens 300 connections, uses each for eleven milliseconds, and drops them. Postgres spends its time forking and reaping backends. That's the whole bug.

What a pooler actually does

A connection pooler sits between your app and Postgres and keeps a small set of real server connections open permanently. Clients connect to the pooler instead, and the pooler hands them a server connection only for as long as they need one. Three modes exist, and only one of them is the answer for serverless:

  • Session mode — a client keeps its server connection until it disconnects. Safe, compatible with everything, and almost useless for short-lived clients, because it multiplexes nothing.
  • Transaction mode — a client gets a server connection for the duration of a transaction, then gives it back. This is where the leverage is: 500 clients can share 20 server connections if none of them holds a transaction open.
  • Statement mode — the connection returns after every single statement. Multi-statement transactions break. Use it only for very specific autocommit-only workloads.

Transaction mode is the default answer, and it's what PandaStack's managed Postgres runs: PgBouncer in transaction mode, accepting up to 500 client connections and multiplexing them onto the database's real backends. You get a pooled connection string alongside the direct one, and you pick per use case.

You need both strings, not one

This is the part people get wrong. The pooled string is for your application at request time. The direct string is for anything that needs a session to itself. Wire both into your environment and use them deliberately.

# Application runtime — short transactions, high concurrency
DATABASE_URL="postgres://pandastack:<pw>@<id>.db.pandastack.ai:6432/pandastack?sslmode=require"

# Migrations, psql sessions, LISTEN/NOTIFY, logical replication
DIRECT_URL="postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack?sslmode=require"

Prisma formalises this split, which is a good sign it's a real distinction and not a vendor quirk:

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")   // pooled
  directUrl = env("DIRECT_URL")     // migrations + introspection
}
Running migrations through a transaction-mode pooler is the single most common way to corrupt a deploy. Advisory locks, which most migration tools use to stop two deploys racing, are session-scoped — through a pooler the lock and the unlock can land on different server connections. The tool then either hangs forever or believes it holds a lock it doesn't. Always point migrations at the direct string.

The five things that break in transaction mode

Switching a connection string is a one-line change, which makes it feel safe. These are the failures that show up later, in order of how often I see them:

  1. Session state vanishes. SET statements, temp tables, and prepared statements live on a server connection you no longer own after COMMIT. If you SET search_path once at connect time, your next query may run with a different path.
  2. Advisory locks leak or hang, as above. pg_advisory_lock is session-scoped; use pg_advisory_xact_lock, which releases at transaction end and is pooler-safe.
  3. LISTEN/NOTIFY doesn't work. A listener needs a persistent session. Use the direct string for that connection, or move the notification path off Postgres entirely.
  4. Protocol-level prepared statements collide. Drivers that cache statements by name across connections will eventually reuse a name on a backend that never prepared it. Most drivers have a flag for this — node-postgres avoids named statements by default, asyncpg needs statement_cache_size=0, Prisma needs pgbouncer=true on the URL.
  5. Long transactions eat the pool. A transaction that opens, calls an external API for four seconds, then commits, holds a server connection for four seconds. Twenty of those and the pool is gone. Do the I/O outside the transaction.

Configuring the client side

A pooler in front of the database doesn't remove the need to size the client pool sensibly — it just changes the failure mode from 'Postgres runs out of RAM' to 'clients queue at the pooler'. In a serverless function, the correct client pool size is almost always 1:

// Serverless: one connection per invocation, reused across warm invocations
import { Pool } from "pg";

const pool = globalThis._pgPool ?? new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,
  idleTimeoutMillis: 10_000,
  connectionTimeoutMillis: 5_000,
});
globalThis._pgPool = pool;   // survive warm reuse, don't leak on cold start

export async function handler(req) {
  const { rows } = await pool.query("select id, email from users limit 20");
  return Response.json(rows);
}

For a long-running server the calculation is different. You want a client pool large enough to keep your workers busy and small enough that queueing happens in your app, where you can see it, rather than at the database. Start at roughly two to four connections per CPU core of the database and measure, rather than starting at 100 and hoping.

# asyncpg through a transaction-mode pooler
import asyncpg

pool = await asyncpg.create_pool(
    dsn=os.environ["DATABASE_URL"],
    min_size=2,
    max_size=10,
    statement_cache_size=0,      # required: named statements break in txn mode
    command_timeout=10,
)

When you can't open a TCP connection at all

Some runtimes — edge functions on V8 isolates, some CI sandboxes, browser-side tooling — cannot open a raw TCP socket to port 5432 no matter how you configure the driver. A pooler doesn't help there, because the problem isn't connection count, it's the transport.

The fix is an HTTP query broker: you POST SQL over HTTPS with a scoped token and get rows back as JSON. PandaStack databases ship one for exactly this reason. It's not a replacement for a real driver — you give up cursors, COPY, and the wire protocol's efficiency — but it's the difference between 'works' and 'doesn't' in an edge runtime.

# $BROKER_URL and $BROKER_TOKEN come back with the database at create time
curl -sS -X POST "$BROKER_URL/v1/query" \
  -H "Authorization: Bearer $BROKER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"database":"pandastack",
       "sql":"select count(*) from orders where created_at > $1",
       "params":["2026-08-01"]}'

How to tell it's working

The number you care about is the ratio of client connections to server connections. If they're equal, the pooler is doing nothing for you — you're almost certainly in session mode, or every client is holding a transaction open. Check from inside Postgres:

-- Real backends, by state
select state, count(*)
from pg_stat_activity
where backend_type = 'client backend'
group by state;

-- The one to watch: transactions parked with nothing running
select pid, now() - xact_start as open_for, query
from pg_stat_activity
where state = 'idle in transaction'
order by xact_start;

Any row in that second query with an open_for of more than a second or two is a bug in your code, and it is the bug that will exhaust the pool under load. Set idle_in_transaction_session_timeout so those get killed rather than accumulating.

The thing pooling can't fix

Pooling solves connection overhead. It does not solve a database that is too small for its working set, and it's worth being honest about which problem you have. If your queries are slow because the data doesn't fit in shared_buffers and every read hits disk, adding a pooler will make the connection errors stop and the latency stay exactly where it was.

The tell is cache hit ratio. Below about 95% on a read-heavy workload and you have a memory problem, not a connection problem — a bigger RAM tier will do more than any pooler setting. On PandaStack that's a clone into a larger tier rather than an in-place resize, which has the useful property that you can test the bigger machine before you cut over to it.

select
  round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2) as cache_hit_pct
from pg_stat_database
where datname = current_database();

The short version

  1. Use the pooled string (transaction mode) for application traffic, the direct string for migrations, LISTEN/NOTIFY, and psql.
  2. Set your driver's client pool to 1 in serverless, and to something small and measured on a long-running server.
  3. Turn off named prepared statements in drivers that use them.
  4. Replace pg_advisory_lock with pg_advisory_xact_lock.
  5. Never hold a transaction open across a network call.
  6. Check cache hit ratio before blaming connections for a latency problem.

Frequently asked questions

Do I still need a connection pooler if I use a long-running server instead of serverless?

Usually less urgently, but often still yes. A long-running server with its own client pool already amortises connection setup, so the acute failure — hundreds of connections created and destroyed per second — goes away. What remains is horizontal scale: ten application instances each holding a pool of twenty connections is two hundred backends on the database, and that arithmetic bites the same way at a lower request rate. A pooler in front lets each instance keep a comfortable local pool while the database sees a small, fixed number of real connections.

What's the difference between session mode and transaction mode pooling?

In session mode, a client holds one server connection from connect to disconnect, so the pooler only saves you the cost of establishing connections — the count Postgres sees is still the count of connected clients. In transaction mode, the server connection is checked out at BEGIN and returned at COMMIT, so hundreds of mostly-idle clients share a handful of backends. Transaction mode is what makes pooling worth doing for serverless, and the price is that anything session-scoped — SET, temp tables, advisory locks, LISTEN — no longer behaves as you expect.

Why do my migrations hang when run through the pooler?

Almost every migration tool takes a Postgres advisory lock so two concurrent deploys can't apply the same migration twice. pg_advisory_lock is session-scoped, and in transaction mode the pooler may hand your lock statement and your subsequent statements to different server connections. The lock is then held by a session your migration no longer has, and the tool waits on a lock it will never get. Point migrations at the direct connection string on port 5432 and the problem disappears.

Should I raise max_connections instead of adding a pooler?

Raising it buys headroom, not throughput, and it costs memory. Each backend reserves its own work_mem allocations and process overhead, so a large max_connections quietly converts RAM you wanted for page cache into per-process overhead — and Postgres also gets slower at scheduling as backend count climbs. Raise it modestly if you're a little short. If you need thousands of clients, that's a pooling problem and no value of max_connections fixes it well.

Can I use a pooler with Prisma, Drizzle, or SQLAlchemy?

Yes, with one setting each. Prisma wants pgbouncer=true on the pooled URL and a separate directUrl for migrations. Drizzle with node-postgres works unmodified because node-postgres doesn't use named prepared statements by default; with postgres.js, set prepare: false. SQLAlchemy with psycopg works as-is, but disable its own connection pooling with NullPool when running in serverless so you're not stacking two pools. asyncpg needs statement_cache_size=0. The common thread is turning off protocol-level prepared statements, since those are bound to a server connection you don't control.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.