# 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.

Source: https://supahost.app/kb/prepared-statement-already-exists · Updated 2026-11-11

## 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:

   ```prisma
   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](/kb/too-many-connections).

## Related

- [Common Prisma errors with Supabase](/kb/prisma-errors)
- [Using SQLAlchemy with Supabase Postgres](/kb/sqlalchemy)
- [Connections and timeouts](/docs/database/connections-and-timeouts)
- [Migrate from Supabase Cloud to SupaHost](/kb/migrate-from-supabase-cloud)
