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_URL on 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 all or deallocate all on a pooled connection removes statements the driver still expects.

How to fix it

  1. 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_one
    
  2. Remove pooler workarounds you no longer need. For Prisma, drop ?pgbouncer=true. With no pooler, url and directUrl can be the same string:

    datasource db {
      provider  = "postgresql"
      url       = env("DATABASE_URL")
      directUrl = env("DATABASE_URL")
    }
    
  3. If you must keep a transaction-mode pooler, turn off named prepared statements in the driver:

    Driver Setting
    Prisma ?pgbouncer=true in the URL
    postgres.js postgres(url, { prepare: false })
    asyncpg statement_cache_size=0
    psycopg 3 prepare_threshold=None
  4. Check 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.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us