prepared statement s0 already exists: por que o pooler quebra
O erro só aparece depois que você troca a conexão direta pelo pooler em modo transaction. A causa está em como o Postgres guarda statements preparados.
O erro tratado aqui
ERROR: prepared statement "s0" already exists (42P05)Ambiente testado
- Prisma 6.5
- Drizzle 0.44
- postgres.js 3.4
- Supavisor · PgBouncer
Contexto: o que estava rodando
Logo depois de mover a aplicação da conexão direta com o Postgres para o pooler em modo transaction — a correção certa para serverless, e que resolveu um problema de esgotamento de conexões — um erro novo começou a aparecer.
Ele não era constante. Com trânsito baixo, nada. Com concorrência, uma fatia das requisições quebrava.
O erro
PrismaClientUnknownRequestError:
Invalid `prisma.produto.findMany()` invocation:
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,
}),
})Com Drizzle sobre postgres.js, a mesma coisa em outra embalagem:
PostgresError: prepared statement "s3" already exists
at ErrorResponse (/var/task/node_modules/postgres/cjs/src/connection.js:788:26)
severity_local: 'ERROR',
code: '42P05',
routine: 'StorePreparedStatement'O número muda — s0, s3, s17. O código 42P05 é sempre o mesmo.
Diagnóstico
Statement preparado pertence à sessão, não à consulta
Quando um driver executa a mesma consulta muitas vezes, ele evita reenviar o SQL
inteiro: manda uma vez com um nome (s0), e depois só manda o nome e os
parâmetros. Isso economiza análise sintática e planejamento a cada chamada.
O detalhe que importa: esse nome vive na sessão da conexão com o Postgres.
Enquanto a sessão for a mesma, s0 continua existindo e significando a mesma
coisa.
O modo transaction destrói essa premissa
Um pooler em modo transaction não dá uma sessão a você. Ele mantém um punhado de conexões reais com o banco e empresta uma delas por transação. Terminada a transação, aquela conexão volta para o bolo e pode ir para outro cliente.
O resultado é este:
cliente A ─┐
cliente B ─┼─→ pooler ─→ conexão real #1 ─→ Postgres
cliente C ─┘
A executa uma consulta → pega a #1 → cria s0 na sessão da #1 → devolve a #1
B executa outra consulta → pega a #1 → tenta criar s0 → já existe → 42P05
O cliente B acha que está numa sessão limpa. Não está: ele herdou uma sessão
onde outro cliente já registrou s0. Os dois drivers numeram do zero, então a
colisão é questão de tempo.

O que não resolve
Reiniciar o pooler. Limpa as sessões e o erro some por alguns minutos. Volta.
Trocar de porta sem pensar. Voltar para a conexão direta faz o erro sumir — e traz de volta o esgotamento de conexões que motivou o pooler. Trocar um problema pelo outro não é correção.
Capturar o erro e tentar de novo. A repetição pode até funcionar, porque a próxima transação talvez pegue outra conexão. É loteria, e ainda dobra a latência da requisição azarada.
A solução
A correção é desligar os statements preparados no cliente. Perde-se a economia de planejamento; ganha-se um sistema que funciona. Na prática, para consultas simples com índice, a diferença de latência é pequena diante do ganho de não manter conexão dedicada por instância.
-
Prisma:
pgbouncer=truena URL da aplicação.Este parâmetro não é entendido pelo Postgres — é uma instrução para o próprio Prisma, que ao vê-lo desativa o cache de statements.
.env.production# Aplicação: pooler em modo transaction, sem statements preparados DATABASE_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" # Migrations: conexão direta, onde statements preparados são bem-vindos DIRECT_URL="postgresql://postgres.REF:SENHA@aws-0-sa-east-1.pooler.supabase.com:5432/postgres"prisma/schema.prismadatasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") } -
postgres.js (e Drizzle sobre ele):
prepare: false.Aqui não existe parâmetro na URL. É opção do cliente:
src/lib/db.tsimport { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; const cliente = postgres(process.env.DATABASE_URL!, { // Obrigatório atrás de pooler em modo transaction. Sem isso, o driver // nomeia statements e colide com os de outro cliente na mesma conexão. prepare: false, max: 1, }); export const db = drizzle(cliente, { schema });O
max: 1segue a mesma lógica: cada instância serverless atende uma requisição por vez, e um pool maior só produz conexão ociosa. -
node-postgres: não nomeie.
O
pgsó prepara quando você passanamena consulta. A correção é remover o campo:src/lib/consultas.ts// Antes — nomeado, quebra atrás do pooler await client.query({ name: 'buscar-produto', text: 'select * from produtos where id = $1', values: [id], }); // Depois — parametrizado, sem nome. Continua imune a SQL injection. await client.query('select * from produtos where id = $1', [id]);Vale registrar: tirar o nome não desliga a parametrização. A proteção contra injeção vem dos
$1, que continuam lá. -
Deixar as migrations pela conexão direta.
Migrations usam recursos de sessão — bloqueios de aviso,
setde configuração, DDL em transações longas — que o modo transaction não sustenta. Elas precisam da porta direta, e é para isso que serve odirectUrl.
Como confirmar que resolveu
Gere concorrência de propósito. O erro não aparece com uma requisição por vez, então o teste precisa de várias ao mesmo tempo:
npx autocannon -c 50 -d 20 https://seu-app.vercel.app/api/produtos
Vinte segundos com cinquenta conexões e zero 42P05 nos logs é evidência
suficiente.
Confirme que a aplicação está mesmo no pooler. Uma URL errada em algum serviço faz o erro voltar de um canto que você não olhou:
select
inet_server_port() as porta,
current_setting('application_name', true) as aplicacao;
Verifique que não sobrou statement preparado:
select count(*) from pg_prepared_statements;
Com o cache desligado nos clientes, essa contagem fica em zero durante o tráfego normal da aplicação.
Armadilhas que sobram depois disso
Poolers recentes sabem lidar com isso — se configurados. Versões novas do PgBouncer e do Supavisor conseguem administrar statements preparados nomeados em modo transaction. Depende de opção habilitada no lado do pooler, e você nem sempre controla essa configuração. Desligar no cliente é o caminho que não exige saber qual versão está rodando.
O modo session não tem esse problema, e não serve para serverless. No modo
session a conexão é sua até desconectar, então s0 é só seu. Só que aí cada
cliente segura uma conexão real, que é exatamente o que o pooler deveria evitar.
Uma URL esquecida derruba tudo. Basta um script, um worker ou um serviço de cron ainda apontando para a conexão direta sem o parâmetro para o erro reaparecer de forma aparentemente aleatória. Vale conferir todas as variáveis de ambiente do projeto, não só a principal.
LISTEN/NOTIFY e advisory locks continuam fora. Eles dependem de sessão
estável e não funcionam em modo transaction, com ou sem statements preparados.
Se a aplicação usa esses recursos, aquele caminho específico precisa de conexão
direta própria.
Continue por aqui
Dados & Migrações
Prisma, Drizzle ou postgres.js: medindo o que importa em serverless
A maioria dos benchmarks de ORM mede a coisa errada. O que pesa em função efêmera é a primeira query, não a milésima — e o script para medir isso.
Dados & Migrações
RLS no Supabase: por que sua query retorna array vazio
Com Row Level Security ligada, o SELECT bloqueado não dá erro — devolve nada. Como depurar a política sem desligar a segurança para "testar".
Dados & Migrações
Prisma em serverless: resolvendo o timeout do connection pool
O pool do Prisma estoura quando cada função serverless abre o próprio conjunto de conexões. O diagnóstico completo e as três correções que aplicamos.