Skip to content

Neon's pooled and unpooled connection strings, and which one to use where

The -pooler host and the direct host are not interchangeable. Runtime queries need the pooler; migrations, advisory locks and session state need the direct endpoint.

Neon4 min readships at docs/solutions/neon/pooled-vs-unpooled-connections.md

Tags: neon · postgres · pgbouncer · connection-pooling · migrations · serverless

Neon hands you two connection strings that differ by six characters:

postgresql://user:pass@ep-cool-name-123456-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=require
postgresql://user:pass@ep-cool-name-123456.eu-central-1.aws.neon.tech/neondb?sslmode=require

They behave differently enough that using the wrong one produces failures that look nothing like a connection problem: a migration that hangs forever, a create index concurrently that errors, a prepared statement "s0" already exists under load, a set search_path that silently does nothing.

What the pooler actually is

The -pooler endpoint is PgBouncer in transaction pooling mode. It accepts a very large number of client connections and multiplexes them onto a small number of real Postgres backends. A backend is assigned to a client for the duration of a transaction and then handed to whoever is next in line.

That last sentence is the whole story. Everything that works on the pooler works because it fits inside one transaction. Everything that breaks on the pooler breaks because it assumed the connection was yours between transactions.

Use the pooled URL for the application

Every query the running app makes should go through -pooler. On serverless this is not a preference, it is arithmetic: each concurrent request can land on its own instance, and each instance opens its own connections. Fifty concurrent invocations holding three connections each is 150 connections; a small Neon compute allows far fewer. The pooler exists so that number becomes "however many instances there are, multiplexed onto a few dozen real sessions".

In this project that is what DATABASE_URL is, and getSql() / getPool() in src/db/client.ts are the only things that read it.

Use the direct URL for anything that needs a session

Four categories, and they are the ones that hurt:

1. Migrations. Every migration tool takes a session-level advisory lock (pg_advisory_lock) so two deploys cannot apply the same migration simultaneously. Session-level locks belong to a connection. Through a transaction pooler, the lock is taken on backend A and looked for on backend B, so it does nothing at best, and at worst the tool waits forever for a lock it already holds somewhere else. Multi-statement DDL can also half-apply, leaving a schema no migration file describes.

2. create index concurrently, vacuum, alter type ... add value. These cannot run inside a transaction block, which is what transaction pooling effectively imposes.

3. Session state. set (without local), listen/notify, temporary tables, cursors held across statements, set role. All of them assume the next statement lands on the same backend. It will not.

4. Long or streaming operations. pg_dump, logical replication, a copy of a large table.

In this project that is DATABASE_URL_UNPOOLED, and directDatabaseUrl() in src/db/client.ts is the accessor. It deliberately throws rather than falling back to a pooled string, because a silent fallback is how a migration ends up on the pooler.

The two query parameters people miss

If your ORM keeps its own client-side pool on top of the pooler (Prisma with the standard engine, postgres.js, pg) two settings matter:

?sslmode=require&pgbouncer=true&connection_limit=1

pgbouncer=true (or your driver's equivalent, e.g. prepare: false for postgres.js) disables named prepared statements. Without it you get intermittent prepared statement "s0" already exists: one instance prepared a statement on a backend, and a different instance was later handed the same backend.

connection_limit=1 shrinks your client-side pool to one connection per instance. This looks wrong and is right. The pooler is doing the pooling; a second connection per instance is idle weight that still occupies a pooler slot. One connection per instance, many instances, multiplexed: that is the design.

Neon's own HTTP driver (neon() from @neondatabase/serverless, which getSql() uses) sidesteps all of this: it is a stateless HTTP request per statement, no socket, no prepared statement cache, no pool to size.

How to tell which one you are holding

const pooled = /-pooler\./.test(process.env.DATABASE_URL ?? "");

That is exactly what isPooled() does, and what bun run verify reports. If verify says "DIRECT URL - the app should use the pooled one", your app is one traffic spike away from remaining connection slots are reserved.

From SQL, watch the real backend count while you load the app:

select count(*) from pg_stat_activity where datname = current_database();

Against the pooled URL under load that number stays flat and small. Against a direct URL it climbs with concurrency.

The failure catalogue

SymptomCause
Migration hangs with no outputMigration run through -pooler; advisory lock lost
create index concurrently cannot run inside a transaction blockSame
prepared statement "s0" already exists, intermittentlyClient-side prepared statements over PgBouncer
sorry, too many clients alreadyApp running on the direct URL, or a per-instance pool that is too large
set search_path appears to be ignoredSession-level set through a transaction pooler
Preview deploy's first migration fails, production is finePreview environment inherited only DATABASE_URL

The rule to keep

The application gets the pooled string. Anything holding a session gets the direct string. Both variables exist in every environment (local, preview, production) even when one of them is only read twice a month. The five seconds it takes to set the second one is the cheapest incident prevention available.