← All posts

prepared statement "s0" already exists: Supabase fix

Written by Varun Krishnan · Founder, dbdiagramr

Published · 8 min read

#supabase#postgres#prisma#troubleshooting

Fix prepared statement "s0" already exists with Prisma on Supabase's Supavisor pooler: add pgbouncer=true, use a direct URL for migrations, and verify.

The short answer

Supabase’s shared transaction pooler on port 6543 is Supavisor, not PgBouncer. Supavisor in transaction mode does not support prepared statements, but Prisma tries to create them. Add ?pgbouncer=true to the pooled URL, and move migrations to a direct or session URL.

That flag name is confusing on Supabase: it is the switch Prisma uses to stop creating named prepared statements. It does not mean the shared pooler is PgBouncer.

For the surrounding connection setup, see the Supabase connection string guide’s Prisma section.

What the error means

Prepared statements belong to a database session. PostgreSQL keeps them for the life of that session, and a statement cannot be used by multiple simultaneous clients. The PostgreSQL PREPARE documentation is explicit on both points.

Transaction pooling gives a client a server connection for one transaction, then returns it to the pool. The next transaction can land on a different backend. A named statement created on one backend may still exist there, or may be missing on the next one. Prisma uses named prepared statements, so it can hit either side of that mismatch.

Supabase’s Prisma troubleshooting guide names the cause directly: Supavisor in transaction mode on port 6543 does not support prepared statements, which Prisma tries to create in the background. The fix it gives is pgbouncer=true on the connection string.

Cause 1: the runtime uses the transaction pooler without the flag

This is the common production case. DATABASE_URL points at port 6543, the app is deployed to a serverless or autoscaling runtime, and the URL has no pgbouncer=true. The first query after a new client or deploy can succeed, then a later query reuses a backend that already has s0, and the next prepare fails.

Serverless makes it easier to see because instances start and stop, but the cause is the pooling mode, not the number of instances by itself.

Cause 2: Prisma Migrate or db push uses the pooled URL

Prisma’s schema engine expects a single connection. Prisma’s pooling guide shows this exact error when Migrate runs through a transaction pooler. The fix is not to add the flag to the migration path. Give Prisma CLI a direct or session URL.

Cause 3: there is no direct URL in the config

If DATABASE_URL is the only URL, the app and the CLI share it. Prisma has no way to send Migrate somewhere safer. Prisma 6 uses directUrl in the datasource block; Prisma 7 puts the direct URL in prisma.config.ts.

The related s0 “does not exist” error

prepared statement "s0" does not exist is the other half of the same problem. The client prepared s0 on one backend, then the transaction moved to a different backend that never saw it. The fix is the same: stop using named prepared statements through transaction pooling, or use a connection mode that preserves the session.

Fix for Prisma 6

Use the pooled URL at runtime with pgbouncer=true, and give Prisma CLI a direct or session URL through directUrl.

# .env
DATABASE_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:6543/postgres?sslmode=require&pgbouncer=true"
DIRECT_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:5432/postgres?sslmode=require"
// prisma/schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

Check: the runtime URL ends in pgbouncer=true, the direct URL has no flag, and both use the username format that matches their mode. In the shared pooler that is postgres.<ref> or <role>.<ref>; a direct connection uses plain postgres.

Fix for Prisma 7

Prisma 7 moves the CLI connection into prisma.config.ts and uses a driver adapter for the runtime. Keep the pooled URL for the adapter and the direct or session URL for the CLI.

# .env
DATABASE_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:6543/postgres?sslmode=require&pgbouncer=true"
DIRECT_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:5432/postgres?sslmode=require"
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: {
    url: env("DIRECT_URL"),
  },
});
// src/db/client.ts
import { PrismaClient } from "../prisma/generated/client";
import { PrismaPg } from "@prisma/adapter-pg";

const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL,
});

export const prisma = new PrismaClient({ adapter });

Check: prisma migrate status and prisma migrate dev use the direct or session URL, while application queries use the pooled URL with the flag.

Run migrations in CI

Point the migration step at the direct connection or shared session pooler. Do not run prisma migrate deploy against 6543.

Prisma 6: keep directUrl = env("DIRECT_URL") in the datasource block. Prisma CLI uses it for migrations and introspection while the app uses the pooled URL.

export DATABASE_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:6543/postgres?sslmode=require&pgbouncer=true"
export DIRECT_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:5432/postgres?sslmode=require"

npx prisma migrate deploy

Prisma 7: set datasource.url to the direct URL in prisma.config.ts. The CLI uses that URL for migrations.

// prisma.config.ts
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: { url: env("DIRECT_URL") },
});
export DIRECT_URL="postgresql://postgres.[ref]:[password]@[pooler-host]:5432/postgres?sslmode=require"

npx prisma migrate deploy

Check: the migration command connects to port 5432, not 6543. In Prisma 6, confirm directUrl is present; in Prisma 7, confirm the config’s datasource.url reads the direct URL.

The PgBouncer 1.21 nuance

Prisma’s pooling guide recommends omitting pgbouncer=true on PgBouncer 1.21 and newer. If you use Supabase’s paid dedicated pooler, which is PgBouncer, follow that version guidance for that pooler.

Do not apply that note to the shared transaction pooler. That pooler is Supavisor, and Supabase’s prepared statement guide still tells Prisma users to add pgbouncer=true in transaction mode. Follow the provider that terminates the connection.

Other drivers

Drizzle with postgres.js: disable prepared statements in the client.

import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL!, { prepare: false });

node-postgres: omit name. Supplying a name creates a prepared statement; an unnamed query does not.

await client.query({ text: "select 1" });

Alternative: use the direct connection or shared session mode, both of which support prepared statements. That is usually the right choice for a persistent backend and for migrations.

How to verify the fix

  1. Run the same query twice from two fresh processes or after a redeploy. Check: both runs succeed, not just the first one.
  2. Run prisma migrate status. Check: it connects through the direct or session URL, not port 6543.
  3. Inspect both URLs. Check: the pooled URL has pgbouncer=true, the direct URL does not, and the usernames match their respective modes.
  4. If the error remains, confirm you edited the deployed environment, not only the local .env.

Tested on

Reproduced locally with PgBouncer in transaction mode (max_prepared_statements=0) to show the mechanism. Supabase’s shared transaction pooler is Supavisor, which also does not support prepared statements in transaction mode (Supabase Prisma troubleshooting).

Environment: PostgreSQL 16.15, PgBouncer 1.26.1, Prisma 6.19.3, Node.js 22.23.3, Docker 29.1.3. The PgBouncer test used pool_mode=transaction, default_pool_size=1, and max_prepared_statements=0.

Without pgbouncer=true, the first Prisma process succeeded and the second process returned:

Error occurred during query execution:
ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError { code: "42P05", message: "prepared statement \"s0\" already exists", severity: "ERROR", detail: None, column: None, hint: None }), transient: false })

With pgbouncer=true on both runs, the same two-process test returned query ok: 1 row(s) both times. A Prisma 7.10.1 config plus @prisma/adapter-pg smoke test against the same transaction pooler also returned query ok: 1 row(s).

The local test uses PgBouncer only to make the mechanism reproducible. The production fix for the shared Supabase pooler comes from Supabase’s Supavisor documentation, not from the local PgBouncer version.

Sources

Need to check the host, port, and username before changing code? Use the PostgreSQL connection string checker.

Visualize your own database

Paste your PostgreSQL connection string and get an interactive ER diagram in under 10 seconds. No signup required.

Related guides

Try it on your database