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
| Symptom | Cause |
|---|---|
| Migration hangs with no output | Migration run through -pooler; advisory lock lost |
create index concurrently cannot run inside a transaction block | Same |
prepared statement "s0" already exists, intermittently | Client-side prepared statements over PgBouncer |
sorry, too many clients already | App running on the direct URL, or a per-instance pool that is too large |
set search_path appears to be ignored | Session-level set through a transaction pooler |
| Preview deploy's first migration fails, production is fine | Preview 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.