all posts

How to use Prisma with a managed Postgres database

Ajay Kumar··10 min read

Prisma against a managed Postgres database is a five-minute setup that becomes a two-hour debugging session the first time a connection pooler enters the picture. The errors it produces — `prepared statement "s0" already exists`, migrations that hang forever, `too many connections` under trivial load — all trace back to the same small set of configuration decisions.

So: the setup, then each of those failures and the setting that prevents it. Examples use PandaStack's managed Postgres because it's what I build, but the settings are the same against any provider with a transaction-mode pooler in front.

You need two connection strings, not one

This is the single most important thing in the post. Managed Postgres typically exposes two ports: a direct connection to the database, and a pooled connection through something like PgBouncer. Prisma needs both, for different jobs.

# .env
# Application queries — pooled (port 6432), with the pgbouncer flag
DATABASE_URL="postgres://pandastack:<pw>@<id>.db.pandastack.ai:6432/pandastack?sslmode=require&pgbouncer=true"

# Migrations and introspection — direct (port 5432)
DIRECT_URL="postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack?sslmode=require"
// schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

generator client {
  provider = "prisma-client-js"
}

`url` is what the client uses at runtime. `directUrl` is what `prisma migrate` and `prisma db pull` use. Getting this the wrong way round is the cause of the two most annoying failures below.

"prepared statement already exists"

The mechanics are worth understanding, because the fix looks arbitrary otherwise. A transaction-mode pooler assigns a backend connection per transaction, not per client session. Prisma uses prepared statements, which are session-scoped — so Prisma prepares a statement named `s0` on one backend, and on the next transaction lands on a different backend where `s0` either doesn't exist or belongs to someone else.

# The fix: tell Prisma it's behind a transaction-mode pooler
DATABASE_URL="postgres://...:6432/pandastack?sslmode=require&pgbouncer=true"

That flag makes Prisma stop using named prepared statements. You lose a small amount of per-query efficiency and gain a client that works. It only belongs on the pooled URL — never on `DIRECT_URL`.

Migrations that hang and never finish

If `prisma migrate deploy` sits there indefinitely, it's almost certainly running through the pooler. Prisma takes a Postgres advisory lock so two concurrent deploys can't apply the same migration twice — and advisory locks are session-scoped. Through a transaction-mode pooler, the lock and the subsequent statements can land on different backends, so the migration waits for a lock it will never be granted.

Setting `directUrl` fixes it, which is why it's in the schema above rather than being optional. If your CI job still hangs, check that the environment actually has `DIRECT_URL` set — a missing variable silently falls back to `url`.

Migrations go through the direct connection. Application traffic goes through the pooler. If you remember one thing from this post, that's the one — it explains both the hanging migration and the prepared-statement error.

"too many connections" with barely any traffic

Prisma opens its own connection pool per `PrismaClient` instance, and the default size is derived from your CPU count. That's sensible for one long-running server and disastrous in two common situations.

The first is serverless, where every concurrent invocation is a separate process with its own pool. Ten concurrent requests can become ten pools of a dozen connections each, against a database whose limit is a hundred. Route serverless traffic through the pooler and cap the client:

DATABASE_URL="postgres://...:6432/pandastack?sslmode=require&pgbouncer=true&connection_limit=1"

The second is hot reloading in development, where every file change constructs a new PrismaClient and the old one's connections linger. The standard workaround is a module-level singleton stashed on `globalThis`:

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "warn"] : ["warn", "error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Import that module everywhere instead of constructing a client per file. One client per process, always.

The deploy configuration

Two commands have to run, in order, and the ordering matters. `prisma generate` produces the typed client and must happen before your code compiles. `prisma migrate deploy` applies pending migrations and should happen once per deploy, not once per instance.

{
  "scripts": {
    "build": "prisma generate && prisma migrate deploy && next build",
    "start": "next start"
  }
}

Note what isn't there: nothing Prisma-related in `start`. Putting `migrate deploy` in the start command means it runs once per instance, so scaling to two replicas races them against each other, and a migration failure turns into a crash loop instead of a failed deploy.

On a platform that builds from a repository, the same thing goes in the build command:

curl -X PATCH https://api.pandastack.ai/v1/apps/$APP_ID \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"build_command":"npx prisma generate && npx prisma migrate deploy && npm run build"}'

TLS and self-signed certificates

Managed Postgres providers require TLS, which Prisma handles with `sslmode=require` in the connection string. If your provider uses a private CA you may need to point at the certificate rather than disabling verification:

# Preferred: verify against the provider's CA
DATABASE_URL="postgres://...?sslmode=verify-full&sslrootcert=./ca.pem"

# Acceptable for most managed providers
DATABASE_URL="postgres://...?sslmode=require"

Resist the urge to set `sslmode=disable` to make an error go away. It works, and it means your database password crosses the network in plaintext.

Testing against a real database

Prisma's query engine has enough behaviour of its own that mocking it tests your mock rather than your queries. A real Postgres for tests is worth the setup — and if your provider supports branching, a throwaway copy of production makes migration rehearsal genuinely easy:

# Branch production into a new database to test a migration
curl -X POST https://api.pandastack.ai/v1/databases/$DB_ID/clone \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label":"migration-rehearsal"}'

# Point Prisma at the branch, time the migration, then delete it
DIRECT_URL="postgres://...@<new-id>.db.pandastack.ai:5432/pandastack?sslmode=require" \
  npx prisma migrate deploy

A migration that takes 200ms against an empty development database can take minutes against production's row counts and indexes, and that's exactly the kind of thing you want to find out before the deploy rather than during it.

The checklist

  1. Two connection strings: pooled in url, direct in directUrl.
  2. Add pgbouncer=true to the pooled URL only.
  3. One PrismaClient per process; a globalThis singleton in development.
  4. connection_limit=1 on the pooled URL for serverless deployments.
  5. prisma generate then prisma migrate deploy, both in the build step, never in start.
  6. sslmode=require at minimum; never disable.
  7. Rehearse migrations against a branch of production and time them.

Frequently asked questions

Why does Prisma throw "prepared statement s0 already exists"?

Because it's talking to a transaction-mode connection pooler. That kind of pooler hands out a backend connection per transaction rather than per session, while prepared statements are session-scoped — so Prisma prepares a statement on one backend and then finds itself on a different one. Adding pgbouncer=true to the pooled connection string tells Prisma to stop using named prepared statements, which resolves it. Put the flag only on the pooled URL, never on the direct one used for migrations.

What is directUrl in schema.prisma actually for?

It gives Prisma's migration and introspection commands a connection that bypasses the pooler. Those commands take a session-scoped advisory lock to prevent two deploys applying the same migration concurrently, and a session-scoped lock through a transaction-mode pooler is unreliable — the symptom is a migration that hangs indefinitely rather than a clear error. Application queries still use url and go through the pooler; only the migration path uses directUrl.

How many connections does Prisma open?

By default it derives a pool size from the machine's CPU count — typically around a dozen for a single long-running server, which is fine. The problem is per-process: in serverless, each concurrent invocation is a separate process opening its own pool, so modest concurrency can exhaust a database's connection limit quickly. Route that traffic through a pooler and set connection_limit=1 on the connection string so each invocation holds a single connection.

Should migrations run in the build command or the start command?

The build command. In start, the migration runs once per instance, so two replicas race each other, and a failed migration becomes a crash loop rather than a failed deploy. In the build step it runs exactly once, in the same environment as the code being shipped, and a failure stops the deploy before any traffic moves. The one thing to keep in mind is that on a blue-green platform the schema changes while the old version is still serving, so migrations must stay backwards-compatible with the running code.

Can I use Prisma without a connection pooler?

Yes, and for a single long-running server it's perfectly reasonable — Prisma's own pool is doing that job already, and skipping the external pooler avoids the prepared-statement configuration entirely. You want a pooler when the number of processes connecting is large or unpredictable: serverless functions, many replicas, or a fleet of background workers. That's the case Prisma's own pool cannot solve, because it operates inside one process.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.