prepared statement "s0" already exists
Why ORMs hit "prepared statement already exists" behind PgBouncer or Supavisor, why SupaHost's direct connections normally avoid it, and the cases where it still happens.
What you see
From Prisma, node-postgres, asyncpg, SQLAlchemy and other drivers:
ERROR: 42P05: prepared statement "s0" already exists
or the reverse:
ERROR: 26000: prepared statement "s3" does not exist
The statement name changes (s0, s1, __asyncpg_stmt_1__), but the pattern is the same: queries fail at random, often only under load.
Why it happens
A prepared statement is a parsed query that Postgres keeps for one session, under a name the driver chooses. Drivers prepare a statement once and reuse it by name on the same connection.
That breaks behind a connection pooler in transaction mode, such as PgBouncer or Supavisor on Supabase Cloud's port 6543. In that mode each transaction can run on a different server connection. Your driver thinks it has one session, but its s0 was prepared on server connection A, and the next query lands on connection B. There, s0 either doesn't exist, or another client already prepared its own s0.
Why it's unusual on SupaHost
SupaHost has no connection pooler. A direct connection through the SSH tunnel (Business and Enterprise) is a normal Postgres session from start to finish, so prepared statements just work. If you're connected to SupaHost the way the dashboard shows, you shouldn't see this error.
It still happens when:
- Your app still uses a Supabase Cloud pooler string. After a move, an old
DATABASE_URLon port 6543 (host*.pooler.supabase.com) keeps the transaction-mode behaviour. - You put your own pooler in front. PgBouncer, pgcat or a cloud proxy in transaction mode between your app and the tunnel brings the same problem back.
- Something resets the session mid-use. Running
discard allordeallocate allon a pooled connection removes statements the driver still expects.
How to fix it
Point your app at the SupaHost connection string from Dashboard → your project → Connect tab → Database → Show connection string, through the tunnel:
postgresql://postgres:<password>@127.0.0.1:5432/p_app_oneRemove pooler workarounds you no longer need. For Prisma, drop
?pgbouncer=true. With no pooler,urlanddirectUrlcan be the same string:datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DATABASE_URL") }If you must keep a transaction-mode pooler, turn off named prepared statements in the driver:
Driver Setting Prisma ?pgbouncer=truein the URLpostgres.js postgres(url, { prepare: false })asyncpg statement_cache_size=0psycopg 3 prepare_threshold=NoneCheck that only one process uses each pooled connection. Sharing a single client object between threads without a pool can also mix up statements.
On SupaHost
- Each project's Data API and Auth already pool their own connections (5 each), so apps using supabase-js don't need a pooler at all.
- Since every direct connection is real, keep client pools small. See Too many connections.