Using Prisma Driver Adapters with node-postgres Pools

This guide is part of Prisma Connection Pool Management. A driver adapter replaces Prisma’s built-in pool with a JavaScript driver you construct yourself — pg for PostgreSQL, @planetscale/database or mysql2 for MySQL, @libsql/client for SQLite-compatible targets. The pool then belongs to the application, which is exactly what teams want when they already have pool metrics, a tuned configuration and a proxy in the path. It also means the URL parameters that used to control pooling stop doing anything, and a service that switches to an adapter without changing its configuration silently gets the driver’s defaults:

# connection_limit=12 in the URL, but with an adapter the pool is pg's default of 10
PrismaClientKnownRequestError: Timed out fetching a new connection from the connection pool.

The objective is a configuration where the adapter’s pool is sized deliberately, its timeouts are ordered inside the request deadline, its metrics are exported, and its behaviour behind a pooler is understood.

Rapid incident diagnosis

Establish which pool is in use. The client’s construction says it directly:

grep -rn "new PrismaClient(" --include=*.ts --include=*.js src/ | head
grep -rn "PrismaPg\|@prisma/adapter-" --include=*.ts --include=*.js src/ | head

An adapter: argument means the driver’s pool is in charge and URL parameters such as connection_limit and pool_timeout are inert.

Read the driver’s pool state. pg.Pool exposes it synchronously, which makes a debug endpoint trivial:

app.get("/debug/pool", (_req, res) => {
  res.json({ total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, max: pool.options.max });
});

waitingCount above zero means requests are queued for a connection; totalCount at max with idleCount zero means the pool is saturated.

Compare with the database’s view. With application_name set on the pool, the server attributes the sessions:

SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'orders-api' GROUP BY state;

Sessions well below replicas × max mean the pool is not the constraint; sessions at that number with waitingCount positive mean it is.

Check which errors you are getting. With an adapter, some failures now come from the driver rather than from Prisma:

Error Source Meaning
P2024 Prisma Timed out waiting for a connection from the adapter’s pool
Connection terminated due to connection timeout pg connectionTimeoutMillis expired while connecting
timeout exceeded when trying to connect pg Same, older message
Connection terminated unexpectedly pg The connection died, often an idle-timeout drop
P1001 Prisma Could not reach the database at all
Where the pool lives with and without a driver adapter Without an adapter, Prisma's query engine owns the pool and reads connection_limit from the URL. With an adapter, the application constructs a pg.Pool and Prisma borrows from it. Built-in pool PrismaClient Query engine pool connection_limit, pool_timeout from the URL Driver adapter PrismaClient with adapter pg.Pool max, idleTimeoutMillis connectionTimeoutMillis URL pool parameters are ignored in the right-hand case, which is the usual surprise
With a driver adapter the pool moves out of Prisma's engine and into the driver, so every pool setting moves with it.

Mathematical sizing or parameter formula

The sizing arithmetic does not change; only the setting names do.

max = ceil( requests_per_second × queries_per_request × mean_query_seconds ÷ 0.7 )
fleet = replicas × workers × max ≤ usable_connections_for_this_service

At 400 requests per second per process, 3 queries of 5 ms each: 400 × 3 × 0.005 = 6 in flight, so max = 9.

Mapping the old settings:

Prisma URL parameter pg.Pool option Note
connection_limit max Same meaning
pool_timeout (seconds) connectionTimeoutMillis Milliseconds; covers waiting for a free connection and connecting
connect_timeout connectionTimeoutMillis The driver merges both into one
— idleTimeoutMillis No Prisma equivalent; important behind NAT
— maxLifetimeSeconds Available in recent pg; useful for rebalancing
statement_cache_size statement_timeout, prepared statement behaviour Driver-specific

Timeout ordering. With one option covering both waiting and connecting, set it from the request deadline and let the connect timeout be implied:

connectionTimeoutMillis ≤ request_deadline − p99_query_time − application_work − margin
idleTimeoutMillis       < shortest network idle timeout

With a 3-second deadline, 40 ms of query time and 200 ms of other work: 2,000 ms is a safe wait, and 120,000 ms suits an environment behind a 350-second NAT idle timeout.

Minimum connections. pg.Pool has no minimum; it opens connections on demand and closes them after idleTimeoutMillis. Where cold-start latency matters, warm the pool at startup:

warm_connections ≈ steady-state in-flight count, opened once at boot
Mapping Prisma URL parameters to pg.Pool options connection_limit maps to max and pool_timeout maps to connectionTimeoutMillis, while idleTimeoutMillis and maxLifetimeSeconds have no Prisma equivalent and must be set deliberately. Prisma URL pg.Pool connection_limit max pool_timeout connect_timeout connectionTimeoutMillis (none) idleTimeoutMillis (none) maxLifetimeSeconds the two green rows are the reason many teams move to an adapter in the first place
Every pool parameter has an equivalent, but two of the most useful ones — idle timeout and lifetime — exist only on the driver side.

Exact remediation & configuration

Construct the pool and the client once:

// src/db.ts
import { Pool } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 9,
  idleTimeoutMillis: 120_000,
  connectionTimeoutMillis: 2_000,
  maxLifetimeSeconds: 1_800,
  application_name: "orders-api",
  keepAlive: true,
  statement_timeout: 2_000,          // server-side guard, applied per connection
});

pool.on("error", (err) => {
  // An idle client errored; pg removes it from the pool. Unhandled, this crashes the process.
  console.error({ err }, "idle client error");
});

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

Warm the pool at startup so the first requests do not pay connection establishment:

export async function warm(n = 3) {
  const clients = await Promise.all(Array.from({ length: n }, () => pool.connect()));
  clients.forEach((c) => c.release());
}

Export the pool’s metrics. This is one of the main benefits of the adapter, because pg.Pool’s counters are plain numbers:

import { Gauge } from "prom-client";

const total = new Gauge({ name: "pg_pool_total", help: "Connections in the pool" });
const idle = new Gauge({ name: "pg_pool_idle", help: "Idle connections" });
const waiting = new Gauge({ name: "pg_pool_waiting", help: "Requests waiting for a connection" });

setInterval(() => {
  total.set(pool.totalCount);
  idle.set(pool.idleCount);
  waiting.set(pool.waitingCount);
}, 10_000).unref();

Shut down in the right order — Prisma first, then the pool:

async function shutdown() {
  server.close();
  await prisma.$disconnect();
  await pool.end();
  process.exit(0);
}
process.on("SIGTERM", shutdown);

Behind a transaction-mode pooler, disable prepared statements at the driver level rather than with Prisma’s pgbouncer=true, which does not apply when an adapter owns the connection:

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,    // points at PgBouncer
  max: 9,
  // node-postgres does not use named prepared statements unless the query specifies `name`,
  // so no extra setting is needed; avoid passing `name` in raw queries.
});

Prisma’s generated queries through the adapter use parameterised statements without names, which is compatible with transaction pooling; the caution applies to hand-written pool.query({ name: ... }) calls elsewhere in the codebase.

Deciding whether the adapter is worth it

The adapter is not automatically better; it moves responsibility.

Reasons to adopt it. The pool’s counters become available for metrics and alerts. Idle timeouts and lifetimes become configurable, which matters behind NAT gateways and for replica rebalancing. The same pg.Pool can be shared with code that does not use Prisma — a raw query for a report, a LISTEN consumer on its own client — so the service has one pool rather than two. And some deployment targets require it, because they provide their own driver.

Reasons to stay with the built-in pool. One fewer dependency and one fewer object to manage. The engine’s pool is tuned for Prisma’s own access pattern, and connection_limit is a single knob that most services never need to move beyond. Teams without pool dashboards gain little from metrics they will not watch.

Mixed usage is where the adapter earns its place. A service that runs Prisma for most queries and raw SQL for a few — a bulk COPY, a recursive CTE, a LISTEN subscription — otherwise holds two pools with two budgets. Sharing one pg.Pool halves the connection footprint and makes the budget arithmetic honest:

// The same pool serves both APIs
const rows = await pool.query("SELECT * FROM mv_daily_totals WHERE day = $1", [day]);
const order = await prisma.order.findUnique({ where: { id } });
Consideration Built-in pool Driver adapter
Pool metrics preview feature only plain counters, always available
Idle timeout and lifetime not configurable configurable
Sharing with raw SQL separate pool needed one pool serves both
Configuration surface one URL driver options plus URL
Dependencies none extra pg and the adapter package
Behind a transaction pooler pgbouncer=true flag driver defaults are already compatible
One pool serving both Prisma and raw SQL With the built-in pool, Prisma and a separate pg.Pool each hold connections. With the adapter, one pg.Pool serves Prisma queries and raw SQL alike. Two pools Prisma engine pg.Pool (raw SQL) 2 × max connections two budgets to track One pool Prisma + adapter raw SQL 1 × max connections one budget the saving is real only if the service actually issues raw SQL
The adapter's clearest win is a service that mixes Prisma and raw SQL: one pool instead of two, one budget instead of two.

Validation & verification

The configured maximum is in effect. Log pool.options.max at startup and confirm the database’s session count matches replicas × max at peak.

Waiting is visible and bounded. Under a load test that saturates the pool, pool.waitingCount must rise and requests must fail with P2024 within connectionTimeoutMillis, not hang.

Idle connections are released. Stop traffic and confirm the session count falls towards zero within idleTimeoutMillis, which is behaviour the built-in pool does not provide.

Errors do not crash the process. Terminate an idle backend on the server and confirm the error handler logs it and the service continues:

SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE application_name = 'orders-api' AND state = 'idle' LIMIT 1;

Shutdown drains. After SIGTERM, the service’s sessions should disappear within a second or two, which proves both $disconnect() and pool.end() ran in order.

Frequently Asked Questions

Do URL parameters still matter with an adapter?
Only the ones the driver understands, because the driver parses the connection string. Prisma-specific pool parameters — connection_limit, pool_timeout — are ignored. Keep them out of the URL to avoid implying otherwise.
Can I use an adapter with Prisma Migrate?
Migrations run through their own connection using the direct URL, independent of the adapter. Keep directUrl configured as before.
Does the adapter change query behaviour?
The generated SQL is the same; the transport is the driver’s. Type mapping can differ subtly for some PostgreSQL types, so a test suite that covers JSON, arrays and numeric precision is worth running when switching.
Is there a performance difference?
For most workloads it is small, and either direction is possible: the adapter removes a layer inside the engine, while the built-in pool is written in Rust. Measure with your own query mix rather than assuming.