Database and migrations

Choose direct or pooled connections

Use direct, session-pooled, or transaction-pooled PostgreSQL connections according to runtime lifetime and feature requirements.

Supabase gives you three ways to reach the same Postgres database. Picking one by copy-paste is how production incidents start. The right choice depends on what runs your code and how long that code lives.

direct        db.abcdefghijkl.supabase.co:5432
session pool  aws-0-eu-central-1.pooler.supabase.com:5432
transaction   aws-0-eu-central-1.pooler.supabase.com:6543

A direct connection is plain Postgres. Every client costs the database one real connection. It fits migrations, pg_dump, and admin work: tools that need full session behavior and run one at a time.

A session pooler hands each client a server connection for its whole session. It behaves like direct Postgres, but the platform manages the supply of connections for you.

A transaction pooler (port 6543) is the interesting one. It reuses one server connection between transactions, so two consecutive queries from your code may land on two different server connections. That breaks features that live on a session: prepared statements, session-level SET, advisory locks. In exchange it absorbs the pattern serverless creates, hundreds of short-lived function invocations that would each hold a direct connection and exhaust the database limit.

Classify your workloads

Let’s run three common workloads through that decision. A migration command: direct. A persistent API server: direct or session pooling, with a bounded pool. A burst of serverless functions: transaction pooler, no exceptions.

A long-lived server should also bound itself. With node-postgres:

import pg from 'pg'

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  connectionTimeoutMillis: 5000,
})

Every replica of your server creates its own pool. So max times the number of replicas must stay under the project’s connection limit. Leave headroom for migrations and for you, when you need emergency admin access at 2 AM.

Match the driver to the mode

The transaction pooler needs the driver to cooperate. Prisma, for example, wants ?pgbouncer=true appended to the pooled connection string so it disables prepared statements. And it still needs the direct string for prisma migrate, which the pooler cannot serve.

Here is what the failure looks like. Everything works locally. Then production starts throwing, now and then:

prepared statement "s0" already exists

That is a driver using prepared statements through transaction pooling. It prepared the statement on one server connection, then the next transaction landed on another one that never saw it, or on one that already had it. Fix the configuration, not the retry logic. A retry hides the error and keeps the wrong connection mode in place.

My advice is to write down, for each piece of your system, which connection string it uses and why. Match the driver and ORM configuration to that mode instead of copying whatever string the dashboard showed first.

Lesson completed