Back to Blog

Prisma "Prepared Statement Already Exists" With PgBouncer — The Real Fix

Published: July 28, 2026
Prisma "Prepared Statement Already Exists" With PgBouncer — The Real Fix

Prisma prepared statement already exists is the kind of error that makes you check your own schema file for a phantom duplicate column, because nothing about the message points at the actual cause. It shows up mid-traffic, disappears if you restart the app, and comes back an hour later. The schema is fine. The query is fine. The problem is one connection pooler quietly recycling Postgres connections faster than Prisma expects, and two requests colliding on a prepared statement name that Postgres insists already exists.

Short answer: this is PgBouncer's transaction-pooling mode fighting with Prisma's named prepared statements, and the fix is a connection string parameter — pgbouncer=true — plus a separate direct connection for migrations. Which exact combination you need depends on your PgBouncer version, which is the part almost nobody's Stack Overflow answer bothers to mention.

A nightclub security guard checking IDs at a velvet rope, representing PgBouncer deciding which connection gets let through

When "Prisma Prepared Statement Already Exists" Actually Shows Up

The exact error text is usually one of these two:

Error: db error: ERROR: prepared statement "s0" already exists
Code
1Invalid `prisma.user.findMany()` invocation: Error occurred during query execution:
2ConnectorError(... "prepared statement \"s1\" already exists" ...)

It almost never appears in local development, because locally you're probably talking straight to Postgres. It shows up the moment you route Prisma through PgBouncer — Supabase's pooler, RDS Proxy's Postgres-compatible mode, or a self-hosted PgBouncer instance — in transaction pooling mode, and specifically once you have enough concurrent traffic that the same underlying Postgres connection gets recycled across more than one logical Prisma session in quick succession.

(If you've been staring at your schema.prisma looking for a query that somehow runs twice — it's not there. The statement name collision happens at the wire protocol level, several layers below anything you wrote.)

Why PgBouncer Transaction Mode Breaks Prisma's Prepared Statements

Prisma's query engine, by default, prepares most queries as named prepared statements — s0, s1, and so on — and reuses those names across subsequent calls for efficiency. That's a sound optimization against a normal, unpooled Postgres connection, where the connection and the prepared statement live and die together.

PgBouncer's transaction-pooling mode breaks that assumption on purpose: it hands your app a Postgres connection for the duration of a single transaction, then returns that same physical connection to the pool to be handed to a different client transaction next. Prisma's own documentation on PgBouncer is direct about this — the Schema Engine and Prisma Client both expect a stable relationship between a connection and the statements prepared on it, and transaction pooling severs that relationship on every transaction boundary. When two different logical sessions land on the same recycled connection and both try to prepare a statement under the same name, Postgres doesn't merge them politely — it throws, because as far as Postgres is concerned, that name is already taken by a statement it never got told to discard.

A tangle of colorful industrial pipes and valves on a wall, representing multiple app connections being routed through one shared pooled pipe

Fix 1: Add pgbouncer=true to the Prisma Connection String

For PgBouncer versions below 1.21.0 — which is still most managed Postgres providers as of this writing — the fix is a single query parameter on the connection string Prisma Client uses:

Bash
1# .env
2DATABASE_URL="postgresql://user:password@pooler-host:6543/mydb?pgbouncer=true"

That flag tells Prisma's query engine to stop using named prepared statements against this connection entirely and fall back to unnamed ones, which don't persist across the connection boundary the way named ones do — so there's nothing left for two recycled sessions to collide over. This is a Prisma-side behavior change, not a PgBouncer setting; PgBouncer itself doesn't know or care that the flag exists.

Fix 2: A Direct, Non-Pooled Connection for Migrations

pgbouncer=true fixes Prisma Client's runtime queries. It does nothing for prisma migrate dev or prisma migrate deploy, which need a single stable connection to run DDL and manage the shadow database — something a transaction pooler can't provide by design. Migrate needs its own, separate, direct connection:

Bash
1# .env
2DATABASE_URL="postgresql://user:password@pooler-host:6543/mydb?pgbouncer=true"
3DIRECT_URL="postgresql://user:password@direct-host:5432/mydb"
TypeScript
1// prisma.config.ts
2import 'dotenv/config';
3import { defineConfig, env } from 'prisma/config';
4
5export default defineConfig({
6  schema: 'prisma/schema.prisma',
7  datasource: {
8    url: env('DIRECT_URL'),
9  },
10});

Your app runtime keeps using DATABASE_URL through the pooler for normal request traffic. Migrate commands use DIRECT_URL, bypassing PgBouncer entirely and talking straight to Postgres on its normal port. Skipping this step is the single most common reason people report the pgbouncer=true fix "half-working" — it fixes queries and then migrate still throws the exact same error, because migrate was never routed through the fix at all.

A rusty yellow industrial shut-off valve, representing the direct connection you open specifically to bypass the pooler for migrations

Fix 3: PgBouncer 1.21.0+ Changes the Recommendation

PgBouncer 1.21.0 shipped its own support for tracking a limited number of named prepared statements per pooled connection via a new setting:

INI
1# pgbouncer.ini
2max_prepared_statements = 200

With that set above zero on a 1.21.0+ instance, Prisma's default named-statement behavior can work through the pooler correctly, and pgbouncer=true becomes unnecessary — on some setups, actively counterproductive, since it forces the slower unnamed-statement path even though the pooler no longer needs it to. The catch is verifying which PgBouncer version you're actually running. Supabase's own troubleshooting page for this exact error is worth checking directly against your provider's dashboard, because "which pooler version am I on" is usually one settings page away, not something you can assume from when you set the project up.

The Opinion Part

Here's the pattern worth naming, because it shows up constantly in this exact genre of bug: an ORM's compatibility with a specific pooler isn't a detail buried in a changelog somewhere — it's part of your actual production contract, and it changes out from under you on someone else's release schedule, not yours. Prisma didn't break this. PgBouncer didn't break this. The gap exists because two separate projects, maintained by two separate teams, each shipped a real improvement that only became compatible again once someone reconciled the versions by hand. Teams already burn an estimated 33–42% of engineering time servicing debt they didn't choose to take on (Stripe Developer Coefficient), and "which pooler version are we actually running, and does our ORM's default behavior still match it" is exactly the kind of quiet compatibility debt that doesn't show up until a production incident forces someone to read a GitHub issue thread at 11pm.

Conclusion

If Prisma is throwing "prepared statement already exists" against a pooled Postgres connection, it's PgBouncer's transaction mode doing exactly what it's designed to do, colliding with prepared statements doing exactly what they're designed to do. Add pgbouncer=true to your runtime connection string if you're on PgBouncer below 1.21.0, wire up a separate DIRECT_URL for Prisma Migrate so schema changes never touch the pooler at all, and check whether max_prepared_statements is available and set on your specific PgBouncer version before assuming the older workaround is still the right one.

If connection pooling is new territory generally, our PostgreSQL performance guide covers why PgBouncer earns its place before you hit this bug at all, and if you're still deciding whether Prisma is the right ORM for a pooled, serverless-heavy stack, our Prisma vs. Drizzle comparison is worth reading before you've written three hundred models against the wrong one. Either way, this is also exactly the kind of wall a database-per-tenant architecture hits faster than people expect — our multi-tenant database guide covers why.

Get the two connection strings pointed at the right places, and enjoy an error message that finally stays fixed instead of coming back the next time traffic picks up.

Frequently Asked Questions

Prisma's query engine creates a named prepared statement (like s0) for most queries and reuses that name across requests. PgBouncer in transaction-pooling mode hands out a different underlying Postgres connection for every transaction, so a prepared statement created on one physical connection doesn't exist on the next one Prisma gets handed — and when two requests happen to land on the same recycled connection, Postgres sees a statement name it thinks it already prepared and throws the error instead of quietly reusing it.

It fixes it for PgBouncer versions below 1.21.0, where the flag tells Prisma's query engine to skip named prepared statements entirely and fall back to unnamed ones that don't collide across pooled connections. For PgBouncer 1.21.0 and later, the recommended path actually flips — you either remove the flag and rely on PgBouncer's own max_prepared_statements support, or use Prisma's driver adapters, depending on which version you're running.

Because prisma=true only changes how Prisma Client executes application queries — it does nothing for Prisma Migrate, which needs a single, stable, non-pooled connection to run schema changes and manage its shadow database. Migrate commands need a direct connection on Postgres's normal port, not the PgBouncer port, configured separately from the pooled URL your application uses at runtime.

It's a PgBouncer 1.21.0+ setting that lets the pooler track and reuse a limited number of named prepared statements per connection instead of rejecting them outright. Setting it above zero lets Prisma's default prepared-statement behavior work through PgBouncer without the pgbouncer=true workaround — but only if you're actually running 1.21.0 or newer, which many managed Postgres providers haven't rolled out yet.

Only if your traffic is low enough that connection exhaustion was never actually your problem — direct connections don't scale past a fairly small number of concurrent serverless invocations or app instances before you hit Postgres's own max_connections limit. For anything beyond a single small app server, a pooler solves a real problem; the fix here is configuring it correctly for Prisma, not removing it.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio