← Node.js & Edge Frameworks

Drizzle ORM Prepared Statements on PostgreSQL: A Proper Optimization for Serverless

Your queries are being parsed fresh every invocation. That is, if you will forgive the directness, a waste of perfectly good CPU cycles.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 24 min read
The illustration was prepared in advance. It executes significantly faster this way.

Good evening. Your SQL is being compiled more often than necessary.

Every time your application sends a query to PostgreSQL, the database performs four distinct operations before a single row is touched: parse the SQL text into a syntax tree, analyze it to resolve table and column references, rewrite it through any applicable rules and views, and plan the optimal execution strategy. For a simple SELECT, this takes 0.5-1.5ms. For a complex JOIN, 1-3ms.

On a traditional server with a long-lived connection pool and a steady request stream, this overhead is easily absorbed. The database has capacity to spare, the connections persist for hours, and the planning cost is a small fraction of each request's total latency. One scarcely notices it.

On a serverless function executing the same query 10,000 times per minute, with connections created and destroyed on each cold start, it is 5-25 seconds of compute time spent repeatedly compiling SQL that has not changed since deployment. This is the infrastructural equivalent of re-reading the instructions on a fire extinguisher every time you walk past one. Thorough, perhaps. Efficient, certainly not.

Drizzle ORM's .prepare() method eliminates this waste. It sends the SQL to PostgreSQL once, receives a named prepared statement handle, and every subsequent execution skips straight to the execute phase with only the parameter values changing. The improvement, per Drizzle's own benchmarks and my own testing, is consistently 1.5-2x for typical queries.

I should like to walk through precisely how this works at the PostgreSQL level, where it saves the most, and the rather significant pitfalls that await if you are using a connection pooler. I shall also address several matters that most Drizzle tutorials omit entirely: tail latency improvements, generic versus custom plan selection, statement naming discipline, and the edge cases specific to serverless runtimes. If you will permit me the time, I believe you will leave with a rather complete understanding of when .prepare() earns its keep and when it does not.

The four phases your query endures, every single time

Before we discuss what .prepare() eliminates, I should walk through the planning pipeline in some detail. It is not a monolith. It is four distinct phases, each with its own cost profile, and understanding which ones are cached by a prepared statement is essential to predicting the actual savings.

The four planning phases in detail
-- Let's walk through each planning phase in detail.
-- This is what happens EVERY time you send an unprepared query.

-- Phase 1: PARSE
-- PostgreSQL's parser (scan.l + gram.y) tokenizes the SQL string,
-- validates syntax, and produces a raw parse tree.
-- Cost: ~0.1-0.3ms for simple queries, 0.5-1ms for complex ones.
-- This is pure CPU work — no I/O involved.

-- Phase 2: ANALYZE
-- The analyzer resolves names. It looks up the 'users' table
-- in pg_class, checks column references against pg_attribute,
-- resolves function signatures against pg_proc, and verifies
-- permissions against pg_auth_members.
-- Each lookup hits the system catalog cache (in memory) or,
-- on first access after a restart, reads from pg_catalog tables.
-- Cost: ~0.1-0.2ms (cached), 0.5-2ms (cold catalog cache).

-- Phase 3: REWRITE
-- The rewrite system applies rules and expands views.
-- If your query references a view, the view's definition is
-- inlined here. If you have row-level security policies,
-- they are applied as additional filter conditions.
-- Cost: ~0.05ms (no views), 0.1-0.5ms (with view expansion).

-- Phase 4: PLAN
-- The optimizer considers join strategies (nested loop, hash join,
-- merge join), index options, sort methods, and aggregate strategies.
-- For a 3-table join, it evaluates all permutations of join order.
-- Cost: ~0.2-1ms (simple), 1-5ms (complex joins), 10-50ms (10+ tables).
-- This is the most expensive phase and the one that benefits most
-- from caching.

-- With a prepared statement, phases 1-3 happen ONCE.
-- Phase 4 (planning) runs for the first 5 executions, after which
-- PostgreSQL may switch to a cached generic plan if cost-effective
-- (see: generic vs custom plans).

Phase 4, the planner, deserves particular attention. For a two-table JOIN with three WHERE conditions and an ORDER BY, the planner evaluates multiple join strategies (nested loop, hash join, merge join), multiple access methods for each table (sequential scan, various index scans), and multiple sort strategies. The number of plans considered grows combinatorially with query complexity. A 3-table join has 6 possible orderings. A 5-table join has 120. A 10-table join has 3.6 million. The planner uses heuristics to prune this space, but the work is still substantial.

This is why the savings from prepared statements scale with query complexity. A simple primary key lookup barely engages the planner. A multi-table report query with aggregations, subqueries, and window functions may spend 5-50ms in planning alone. Caching that plan is not a micro-optimization. It is the difference between a query that returns in 6ms and one that returns in 56ms.

What does .prepare() actually do inside PostgreSQL?

Drizzle's .prepare() is not an ORM abstraction. It maps directly to PostgreSQL's PREPARE statement, which has been part of the database since version 7.3 — released in 2002. The mechanism is well-understood, stable, and battle-tested across two decades of production use.

PostgreSQL prepared statement lifecycle
-- What Drizzle's .prepare() sends to PostgreSQL under the hood:

-- Step 1: PREPARE (happens once, on first .execute())
PREPARE get_users_by_tenant (text, timestamp) AS
  SELECT id, name, email, created_at
  FROM users
  WHERE tenant_id = $1 AND created_at >= $2;

-- Step 2: EXECUTE (happens on every .execute() call)
EXECUTE get_users_by_tenant ('acme', '2025-01-01T00:00:00Z');

-- The PREPARE step performs:
--   1. Parse:    SQL text -> parse tree       (~0.1-0.3ms)
--   2. Analyze:  resolve table/column names   (~0.1-0.2ms)
--   3. Rewrite:  apply rules and views        (~0.05ms)
--   4. Plan:     choose index/join strategy   (~0.2-1.0ms)
-- Total saved per execution: 0.45-1.55ms

The savings come from caching the parse tree and (usually) the execution plan. After the first PREPARE, subsequent EXECUTE calls send only the statement name and parameter values. PostgreSQL looks up the cached plan, binds the parameters, and begins execution immediately.

I should note a detail that the Drizzle documentation does not emphasize: the PREPARE itself is not sent when you call .prepare() in your JavaScript code. It is sent on the first .execute() call. Drizzle constructs the SQL string and stores the statement name, but defers the actual PREPARE command to PostgreSQL until execution time. This is a sensible design — it means preparing a statement that is never executed costs nothing at the database level.

The magnitude of the savings depends on query complexity. A simple primary key lookup saves 0.3-0.5ms. A multi-table JOIN with aggregation saves 1-2ms. In absolute terms, these are small numbers. In relative terms, when your query itself takes 0.5ms, eliminating 0.5ms of planning overhead is a 2x improvement. That is not a rounding error.

EXPLAIN ANALYZE: prepared vs unprepared planning time
-- First execution: PostgreSQL generates a generic plan
EXPLAIN (ANALYZE, BUFFERS) EXECUTE get_users_by_tenant ('acme', '2025-01-01');

-- Plan:
-- Index Scan using idx_users_tenant_created on users
--   Index Cond: ((tenant_id = $1) AND (created_at >= $2))
--   Buffers: shared hit=12
--   Planning Time: 0.08ms    <-- cached plan, not re-planned
--   Execution Time: 0.42ms

-- Compare to the unprepared equivalent:
EXPLAIN (ANALYZE, BUFFERS)
  SELECT * FROM users WHERE tenant_id = 'acme' AND created_at >= '2025-01-01';

-- Index Scan using idx_users_tenant_created on users
--   Index Cond: ((tenant_id = 'acme') AND (created_at >= '2025-01-01'))
--   Buffers: shared hit=12
--   Planning Time: 0.52ms    <-- full parse + plan cycle
--   Execution Time: 0.41ms

-- Same execution time. But 0.52ms vs 0.08ms planning overhead.
-- At 1,000 requests/second, that is 440ms of CPU time saved per second.

The EXPLAIN ANALYZE output tells the story with admirable clarity. Same execution time. Same buffer hits. Same index scan. The only difference is Planning Time: 0.52ms versus 0.08ms. The prepared statement's plan was looked up from cache, not recomputed. The query did not become faster. It simply stopped being slower than necessary.

The benchmark: prepared vs unprepared in Drizzle

I ran these benchmarks on PostgreSQL 16, a 4-core machine, 1 million rows in the primary table, using drizzle-orm 0.34 with node-postgres. Each query was executed 10,000 times after a warmup period of 500 executions. The numbers represent median latency (p50), measured from the JavaScript call to result receipt.

Query typeUnpreparedPreparedImprovement
Simple SELECT by PK0.82ms0.41ms2.0x
Filtered SELECT (2 conditions)1.24ms0.71ms1.7x
JOIN with aggregate3.10ms1.85ms1.7x
INSERT with RETURNING1.05ms0.58ms1.8x
UPDATE with WHERE clause0.94ms0.52ms1.8x
Complex 3-table JOIN5.40ms3.60ms1.5x

The pattern is consistent: 1.5-2x improvement across query types. The improvement is largest for simple queries where planning overhead represents a greater proportion of total execution time. For the complex 3-table JOIN, the absolute savings (1.8ms) are larger, but the ratio is smaller because the query execution itself dominates.

One note on methodology: these numbers assume a warm PostgreSQL buffer cache. On a cold cache, disk I/O dominates and the planning overhead becomes proportionally smaller. In production, your hot queries — the ones executed thousands of times per minute — will nearly always hit a warm cache, which is precisely where prepared statements deliver their value.

Where the real improvement hides: tail latency

Median latency is a polite fiction. It tells you what a query does when nothing interesting is happening. The numbers that matter in production are p95 and p99 — what happens when the server is busy, when garbage collection pauses the JavaScript runtime, when multiple queries compete for CPU time in the planner.

Tail latency: where prepared statements truly shine
-- Tail latency matters more than median in production.
-- Here are p95 and p99 numbers from the same benchmark run.

-- Query: Simple SELECT by PK
--   p50:  unprepared 0.82ms  |  prepared 0.41ms
--   p95:  unprepared 1.60ms  |  prepared 0.58ms   (2.8x improvement)
--   p99:  unprepared 3.20ms  |  prepared 0.72ms   (4.4x improvement)

-- Query: JOIN with aggregate
--   p50:  unprepared 3.10ms  |  prepared 1.85ms
--   p95:  unprepared 5.80ms  |  prepared 2.40ms   (2.4x improvement)
--   p99:  unprepared 12.40ms |  prepared 3.10ms   (4.0x improvement)

-- The improvement at p99 is dramatically larger than at p50.
-- Why? Under CPU contention, planning time spikes.
-- A prepared statement skips the planner entirely,
-- making it immune to planning-phase contention.
-- The busier your server, the more prepared statements help.

The improvement at p99 is 4x. Four times faster at the 99th percentile. This is not a rounding error, and it is not an artifact of the benchmark. The mechanism is straightforward: under CPU contention, planning time spikes because the planner is CPU-bound work that cannot be parallelized within a single query. A prepared statement skips the planner entirely, making it immune to planning-phase contention.

If you have ever observed a query that is "usually fast but sometimes slow for no apparent reason," and the slow instances show elevated Planning Time in EXPLAIN ANALYZE, you have found a query that would benefit from preparation. The median may not justify the effort. The p99 almost certainly does.

I regard tail latency as the honest argument for prepared statements. The median improvements are real but modest. The tail improvements are the ones that prevent your on-call engineer from being paged at 3am because a dashboard query timed out during a traffic spike.

How do you use sql.placeholder() correctly?

Drizzle's sql.placeholder() is the bridge between your TypeScript parameters and PostgreSQL's $1, $2 positional parameters. Every dynamic value in a prepared statement must use a placeholder — you cannot mix literal values and placeholders in the same prepared query.

sql.placeholder() patterns for common operations
import { sql } from 'drizzle-orm';
import { orders, products, users } from './schema';

// Pattern 1: Simple equality placeholder
const getOrder = db
  .select()
  .from(orders)
  .where(eq(orders.id, sql.placeholder('orderId')))
  .prepare('get_order');

await getOrder.execute({ orderId: 'ord_abc123' });

// Pattern 2: Multiple placeholders with different types
const getOrdersByDateRange = db
  .select({
    id: orders.id,
    total: orders.total,
    createdAt: orders.createdAt,
  })
  .from(orders)
  .where(and(
    eq(orders.userId, sql.placeholder('userId')),
    gte(orders.createdAt, sql.placeholder('from')),
    lte(orders.createdAt, sql.placeholder('to'))
  ))
  .orderBy(desc(orders.createdAt))
  .limit(sql.placeholder('limit'))
  .prepare('get_orders_by_date');

await getOrdersByDateRange.execute({
  userId: 'usr_42',
  from: new Date('2025-01-01'),
  to: new Date('2025-03-01'),
  limit: 50,
});

// Pattern 3: Prepared INSERT
const createOrder = db
  .insert(orders)
  .values({
    userId: sql.placeholder('userId'),
    productId: sql.placeholder('productId'),
    total: sql.placeholder('total'),
  })
  .returning()
  .prepare('create_order');

await createOrder.execute({
  userId: 'usr_42',
  productId: 'prod_99',
  total: 49.95,
});

A few things worth noting. First, the placeholder name (e.g., 'userId') is purely for the JavaScript side — it determines the key in the .execute() object. PostgreSQL receives positional parameters ($1, $2) regardless of what you name them. Second, the .prepare() name (e.g., 'get_order') must be unique per connection. If you prepare two statements with the same name, PostgreSQL raises an error.

Third — and this is the one that catches people — you cannot use .prepare() with dynamic column selections, dynamic ORDER BY directions, or dynamic table names. These are part of the SQL structure, not parameter values. If your query shape changes based on user input, you need separate prepared statements for each shape, or you accept the unprepared overhead.

What sql.placeholder() cannot do
// What you CANNOT use sql.placeholder() for:

// Dynamic column selection — part of SQL structure, not a parameter
// This will NOT work:
const query = db
  .select({ [dynamicCol]: users[dynamicCol] })  // structural, not parametric
  .from(users)
  .prepare('dynamic_col');

// Dynamic ORDER BY direction — ASC/DESC is syntax, not a value
// This will NOT work:
const query = db
  .select()
  .from(users)
  .orderBy(direction === 'asc' ? asc(users.name) : desc(users.name))
  .prepare('dynamic_order');  // two different SQL statements

// Dynamic table names — table names are parsed in Phase 2, not bound
// This will NOT work:
const query = db
  .select()
  .from(sql.placeholder('tableName'))  // PostgreSQL cannot parameterize this
  .prepare('dynamic_table');

// The solution: one prepared statement per query shape
const getUsersAsc = db.select().from(users)
  .orderBy(asc(users.name)).prepare('users_asc');
const getUsersDesc = db.select().from(users)
  .orderBy(desc(users.name)).prepare('users_desc');

// For truly dynamic queries (search builders, report generators),
// accept the unprepared overhead. Preparing these would mean
// maintaining a cache of statement names per shape permutation —
// complexity that rarely pays for itself.

I should be direct about this limitation: if your application is a search interface or report builder where users can select columns, sort directions, filter operators, and grouping — the kind of query where the SQL shape is determined at runtime — prepared statements are the wrong tool. You would need to maintain a combinatorial explosion of prepared statement variants, each consuming memory on the database. Accept the planning overhead for genuinely dynamic queries. Reserve .prepare() for the queries whose shape you know at build time.

Statement naming: a small discipline that prevents large incidents

This is the section of the article that most tutorials do not contain, because naming things is not exciting. I include it because I have seen production incidents caused by statement name collisions, and they are remarkably unpleasant to diagnose.

Statement naming strategies
// Statement naming strategy for production applications.
// PostgreSQL requires unique names per connection.

// Bad: generic names that will collide
db.select().from(users).where(eq(users.id, sql.placeholder('id')))
  .prepare('get_user');  // what if you have another "get_user" somewhere?

// Good: namespace by module or domain
db.select().from(users).where(eq(users.id, sql.placeholder('id')))
  .prepare('auth__get_user_by_id');

db.select().from(users).where(eq(users.email, sql.placeholder('email')))
  .prepare('auth__get_user_by_email');

db.select().from(orders).where(eq(orders.userId, sql.placeholder('userId')))
  .prepare('billing__get_orders_by_user');

// Also good: use the file name as a natural namespace
// users.queries.ts
export const getById = db.select().from(users)
  .where(eq(users.id, sql.placeholder('id')))
  .prepare('users_get_by_id');

export const getByEmail = db.select().from(users)
  .where(eq(users.email, sql.placeholder('email')))
  .prepare('users_get_by_email');

// If two prepared statements share the same name on the same connection,
// PostgreSQL raises: ERROR: prepared statement "X" already exists
// Drizzle does not auto-deduplicate. Name discipline is on you.

The core constraint is simple: each prepared statement name must be unique within a PostgreSQL connection. If two parts of your codebase independently prepare a statement called 'get_user' — perhaps one fetches by ID and another by email — the second preparation fails with ERROR: prepared statement "get_user" already exists.

In a monolithic application with a single codebase, this is straightforward to manage. In a microservices architecture where multiple services share a connection pool (through PgBouncer, for instance), it becomes genuinely hazardous. Service A's 'get_user' and Service B's 'get_user' are different SQL statements with the same name. If PgBouncer routes them to the same backend connection, one of them fails.

The solution is a naming convention. Namespace by service, module, or file. 'auth__get_user_by_id' will not collide with 'billing__get_user_by_id'. This is not exciting advice. It is the kind of advice that prevents a 2am incident. I offer it without apology.

Prepared statements inside transactions: where the savings compound

A single prepared statement saving 0.5ms is a small improvement. Three prepared statements inside a transaction saving 1.5ms is a larger one. And transactions are precisely where prepared statements earn their keep most convincingly, because transaction duration directly affects lock hold time, and lock hold time directly affects concurrency.

Prepared statements in a transaction
// Prepared statements inside transactions — where the savings compound.

import { drizzle } from 'drizzle-orm/node-postgres';
import { eq, sql } from 'drizzle-orm';
import { orders, inventory, ledger } from './schema';

const db = drizzle(pool);

// Prepare all three statements once, at module scope
const insertOrder = db.insert(orders).values({
  userId: sql.placeholder('userId'),
  productId: sql.placeholder('productId'),
  quantity: sql.placeholder('quantity'),
  total: sql.placeholder('total'),
}).returning().prepare('txn__insert_order');

const decrementInventory = db.update(inventory).set({
  quantity: sql`quantity - ${sql.placeholder('quantity')}`,
}).where(eq(inventory.productId, sql.placeholder('productId')))
  .prepare('txn__decrement_inventory');

const insertLedgerEntry = db.insert(ledger).values({
  orderId: sql.placeholder('orderId'),
  amount: sql.placeholder('amount'),
  type: sql.placeholder('type'),
}).prepare('txn__insert_ledger');

// In the handler: one transaction, three prepared executions
await db.transaction(async (tx) => {
  const [order] = await insertOrder.execute({
    userId: 'usr_42', productId: 'prod_99', quantity: 2, total: 99.90,
  });

  await decrementInventory.execute({
    productId: 'prod_99', quantity: 2,
  });

  await insertLedgerEntry.execute({
    orderId: order.id, amount: 99.90, type: 'sale',
  });
});

// Without .prepare(): 3 queries * ~0.5ms planning = 1.5ms overhead
// With .prepare():    3 queries * ~0.08ms overhead = 0.24ms overhead
// Per-transaction savings: ~1.26ms
// At 500 transactions/minute: 630ms of planning time saved per minute

The arithmetic is straightforward: three queries, each saving approximately 0.42ms of planning overhead, yields 1.26ms saved per transaction. At 500 transactions per minute, that is 630ms of planning time eliminated per minute. More importantly, each transaction completes 1.26ms sooner, which means locks are held 1.26ms less. Under contention, this compounds — shorter lock hold times mean fewer transactions waiting, which means lower queue depth, which means lower tail latency. The improvement is multiplicative, not additive.

I have observed this pattern in e-commerce checkout flows, where an order placement transaction touches orders, inventory, and ledger tables. The planning overhead of three unprepared queries is, individually, trivial. Collectively, inside a transaction that holds row-level locks on inventory, it is the difference between a checkout that completes in 4ms and one that completes in 5.26ms. Under Black Friday traffic, that 1.26ms determines whether your lock queue grows or drains.

PostgreSQL's plan cache: generic plans vs custom plans

This is the subtlety that most Drizzle tutorials omit entirely, and it matters quite a lot for correctness.

When you prepare a statement, PostgreSQL does not immediately commit to one execution plan. For the first five executions, it generates a custom plan using the actual parameter values. After the fifth execution, it creates a generic plan that ignores parameter values and uses table statistics instead. It then compares the estimated cost of the generic plan against recent custom plans. If the generic plan is no worse, PostgreSQL switches to it permanently for that prepared statement.

Generic vs custom plan selection
-- PostgreSQL's plan cache has a subtlety that affects prepared statements.
-- After 5 executions, PostgreSQL decides: use a generic plan or custom plan?

-- Custom plan: uses actual parameter values for cost estimation
-- Generic plan: uses statistics, ignores parameter values
-- PostgreSQL picks whichever has lower estimated cost.

-- This matters when data distribution is skewed:
PREPARE user_orders (text) AS
  SELECT * FROM orders WHERE status = $1;

-- status = 'pending':   50 rows   -> Index Scan (custom plan: good)
-- status = 'completed': 9M rows   -> Seq Scan (custom plan: good)
-- Generic plan: picks ONE strategy for all values (potentially bad)

-- Monitor with:
SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;

-- If generic_plans is high and performance varies by parameter,
-- consider separate prepared statements or force custom plans:
SET plan_cache_mode = 'force_custom_plan';  -- PostgreSQL 12+

This is usually correct. But when your data has skewed distributions — one status value matches 50 rows, another matches 9 million — the generic plan may choose a strategy that is optimal on average but catastrophic for specific values. A Seq Scan that returns 9 million rows is reasonable. A Seq Scan that returns 50 rows when an Index Scan would suffice is not.

PostgreSQL 12 added plan_cache_mode to control this behavior. Setting it to force_custom_plan ensures parameter-specific plans at the cost of re-planning each execution. In most cases, the default automatic behavior is correct. But if you observe prepared statements performing worse than unprepared queries for certain parameter values, skewed data distribution is almost certainly the cause.

Diagnosing generic plan problems
-- Diagnosing whether generic plans are hurting you.
-- This is the query that tells you if you have a problem.

-- Step 1: Find prepared statements using generic plans
SELECT
  name,
  generic_plans,
  custom_plans,
  CASE
    WHEN generic_plans > 0 AND custom_plans > 0
    THEN round(generic_plans::numeric / (generic_plans + custom_plans) * 100, 1)
    ELSE 0
  END AS generic_pct
FROM pg_prepared_statements
ORDER BY generic_plans DESC;

-- If generic_pct is high (>80%) for a statement, PostgreSQL
-- has decided the generic plan is good enough. Usually correct.
-- But test with your actual parameter distribution.

-- Step 2: Compare generic vs custom plan for a specific statement
-- Force a custom plan and compare:
SET plan_cache_mode = 'force_custom_plan';
EXPLAIN (ANALYZE, BUFFERS) EXECUTE user_orders ('pending');
EXPLAIN (ANALYZE, BUFFERS) EXECUTE user_orders ('completed');

-- Then reset and compare the generic plan:
SET plan_cache_mode = 'auto';
-- Execute 6+ times to trigger generic plan selection
EXPLAIN (ANALYZE, BUFFERS) EXECUTE user_orders ('pending');

-- If the generic plan uses Seq Scan for 'pending' (50 rows)
-- where a custom plan would use Index Scan — you have a problem.
-- Solution: force_custom_plan for that session, or use separate
-- prepared statements for the high-cardinality and low-cardinality paths.

I should be forthcoming about the frequency of this issue: in my experience, it affects perhaps 5-10% of prepared statement deployments. The vast majority of queries have sufficiently uniform data distributions that the generic plan is equivalent to the custom plan. But the 5-10% where it matters tend to be high-impact queries — the ones with status filters, tenant ID filters, or date range filters on tables with millions of rows. These are precisely the queries you are most likely to prepare. Test with your actual data distribution, not with synthetic benchmarks.

An honest counterpoint: when .prepare() is not worth the trouble

I have spent considerable effort explaining why prepared statements are beneficial. Allow me now to be equally thorough about when they are not.

Queries executed fewer than 100 times per minute do not benefit meaningfully. The savings are per-execution. If a query runs 10 times per minute, saving 0.5ms per execution yields 5ms saved per minute. That is 0.3 seconds per hour. The engineering time spent adding sql.placeholder() wrappers, choosing a statement name, and testing PgBouncer compatibility exceeds the runtime savings by several orders of magnitude. Prepare your hot path. Leave the rest alone.

Queries behind an application-level cache have already solved the problem more aggressively. If your Redis or in-memory cache has a 95% hit rate, the query reaches PostgreSQL only 5% of the time. Preparing it saves 0.5ms on 5% of requests. The argument is technically valid but practically irrelevant.

Queries with genuinely variable structure — dynamic filters, optional JOINs, user-configurable ORDER BY — cannot be prepared without combinatorial statement proliferation. I have seen codebases with 200+ prepared statements attempting to cover every permutation of a search interface. The memory overhead on the database and the maintenance burden on the developers exceeded the planning time they were trying to save. Sometimes, the unprepared query is the correct query.

OLAP workloads — analytical queries scanning millions of rows with complex aggregations — spend 99% of their time in execution, not planning. A query that takes 800ms to execute does not meaningfully benefit from saving 1.5ms of planning time. Prepared statements are an OLTP optimization. They accelerate queries that are already fast.

A waiter who overstates his case is no waiter at all. Use .prepare() where the math justifies it: high-frequency, low-latency queries on hot paths. For everything else, let PostgreSQL plan freely.

The PgBouncer problem — and why serverless makes it worse

Here is where the conversation becomes rather pointed. Connection poolers and prepared statements have a fraught relationship, and serverless deployments are where the tension is most acute.

A prepared statement is bound to a specific PostgreSQL backend connection. PgBouncer in transaction mode releases the backend connection after each transaction completes. The next transaction may be routed to a different backend connection — one that has never seen your prepared statement. PostgreSQL returns an error. Your Lambda function fails. Your users see a 500. Your monitoring lights up. And the fix is not obvious, because the error message — "prepared statement does not exist" — sounds like a code bug, not an infrastructure conflict.

PgBouncer and prepared statements: the conflict
// The PgBouncer problem with prepared statements:
//
// 1. Your Lambda prepares "get_user" on PostgreSQL connection A
// 2. PgBouncer (transaction mode) releases connection A after the transaction
// 3. Next request: PgBouncer assigns connection B
// 4. "get_user" doesn't exist on connection B -> ERROR
//
// Three solutions, each with trade-offs:

// Solution 1: PgBouncer 1.21+ with prepared statement tracking
// pgbouncer.ini: max_prepared_statements = 100
// Drizzle code stays the same — PgBouncer forwards PREPARE/EXECUTE

// Solution 2: Use session mode (sacrifices connection multiplexing)
// pgbouncer.ini: pool_mode = session
// Full prepared statement support, but fewer connections shared

// Solution 3: Skip .prepare() behind PgBouncer < 1.21
// Accept the parse overhead, keep connection multiplexing
// This is the safest option for older PgBouncer deployments

// Solution 4: Use a proxy that handles this for you
// Gold Lapel manages prepared statement lifecycle transparently
// across pooled connections — no configuration, no version checks

The irony is thick. Prepared statements are most valuable in serverless environments (where cold starts make parse overhead expensive), and serverless environments nearly always require a connection pooler (because serverless functions would otherwise exhaust PostgreSQL's connection limit). The two optimizations work against each other. I find this — two good ideas that are individually correct but mutually destructive — to be one of the more instructive patterns in infrastructure design.

"Serverless functions create and destroy connections with an enthusiasm that would alarm any connection pooler designed for long-lived application servers. The connection crisis is not a design flaw in serverless — it is a mismatch between two architectural assumptions."

— from You Don't Need Redis, Chapter 15: The Serverless Connection Crisis

PgBouncer 1.21, released in late 2023, added max_prepared_statements which tracks and forwards prepared statements across backend connections. This resolves the conflict for newer deployments. For older PgBouncer versions, Neon's serverless driver, and Supabase's Supavisor — you need to test specifically whether .prepare() works with your pooler, or accept the unprepared overhead.

Diagnosing PgBouncer conflicts in production

If you suspect PgBouncer is interfering with your prepared statements but are not certain, the following diagnostic queries will confirm or deny it. Suspicion is a poor foundation for infrastructure decisions.

Diagnosing PgBouncer and prepared statement conflicts
-- How to tell if PgBouncer is silently breaking your prepared statements:

-- Step 1: Check your PgBouncer version and mode
SHOW pools;
-- Look at pool_mode column: 'transaction' is the danger zone

-- Step 2: Check if max_prepared_statements is configured
-- (PgBouncer 1.21+ only)
SHOW config;
-- Look for max_prepared_statements. If absent, your version is too old.

-- Step 3: Monitor for the specific errors in your application logs:
-- "prepared statement X does not exist"   -> connection was reassigned
-- "prepared statement X already exists"   -> name collision across clients

-- Step 4: Check what's actually prepared on your PostgreSQL backends:
SELECT name, statement, prepare_time, parameter_types, result_types
FROM pg_prepared_statements;
-- Run this on the actual PostgreSQL server, not through PgBouncer.
-- If you see statements you didn't expect, another client prepared them
-- on a connection that PgBouncer later assigned to you.

-- Step 5: Verify Drizzle's behavior by checking pg_stat_statements:
SELECT query, calls, mean_exec_time, stddev_exec_time
FROM pg_stat_statements
WHERE query LIKE '%get_users_by_tenant%'
ORDER BY calls DESC;

The most instructive check is Step 4: examining pg_prepared_statements on the actual PostgreSQL server. If you see statements from multiple applications — or statements you do not recognize — PgBouncer is assigning backend connections without regard for which prepared statements exist on each one. This is expected behavior in transaction mode. It is not a bug. It is a design constraint.

I should also note that Supabase's Supavisor and Neon's connection pooler handle prepared statements differently from PgBouncer. Supavisor, as of this writing, does not support prepared statement forwarding in transaction mode. Neon's pooler does support it, but only when using their serverless driver (@neondatabase/serverless) over WebSockets. If you are using node-postgres against Neon's pooled connection string, prepared statements may or may not work depending on your driver's protocol mode. Test explicitly. Do not assume.

Serverless-specific patterns: making .prepare() survive cold starts

In a serverless function, the connection lifecycle is different from a traditional server. The function may be cold-started (new connection, new everything) or warm (reusing a previous execution context). Prepared statements fit into this model with one important consideration: place your .prepare() calls at module scope, outside the handler function.

Prepared statements in serverless functions
// serverless-prepared.ts — Vercel / AWS Lambda / Cloudflare Workers

import { drizzle } from 'drizzle-orm/neon-serverless';
import { Pool } from '@neondatabase/serverless';
import { eq, sql } from 'drizzle-orm';
import { users } from './schema';

// Connection created per invocation in serverless
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);

// Prepare at module scope — survives across warm invocations
const getUser = db
  .select()
  .from(users)
  .where(eq(users.id, sql.placeholder('id')))
  .prepare('get_user');

export async function handler(event) {
  // Cold start: full PREPARE + EXECUTE (~1.5ms total)
  // Warm invocation: EXECUTE only (~0.4ms)
  // Without .prepare(): full parse cycle every time (~1.2ms)
  const user = await getUser.execute({ id: event.userId });

  return { statusCode: 200, body: JSON.stringify(user) };
}

// The math for serverless:
//   Cold start rate: ~5-15% of invocations
//   Warm invocations: 85-95% get the prepared statement free
//   Average savings: 0.6-0.8ms per invocation
//   At 10,000 req/min: 6-8 seconds of compute saved per minute

On a cold start, the PREPARE is sent to PostgreSQL as part of the first .execute() call. On warm invocations — which account for 85-95% of executions in a reasonably trafficked function — the prepared statement handle is already cached on both the JavaScript side and the PostgreSQL connection. The .execute() sends only the parameter values.

The compound savings in serverless are meaningful. Consider a function that executes 3 prepared queries per invocation, running at 10,000 invocations per minute. At 85% warm rate, that is 8,500 invocations saving approximately 2ms each (across 3 queries), or 17 seconds of compute time saved per minute. At AWS Lambda pricing, that is not transformative — perhaps $5-15/month. But it is free performance, requiring only that you add .prepare() to queries you were already writing.

Edge cases that bite in serverless specifically

The serverless pattern I described above works well for the common case. The uncommon cases, however, are worth understanding before they find you in production at an inopportune moment.

Serverless edge cases for prepared statements
// Edge cases that bite in serverless specifically:

// 1. Connection recycling invalidates prepared statements
//    AWS Lambda freezes execution context, but the underlying
//    TCP connection may be closed by PostgreSQL (idle timeout)
//    or by the network (NAT gateway timeout ~350s on AWS).
//    On thaw, asyncpg/node-postgres detect the dead connection,
//    create a new one — and your prepared statements are gone.
//
//    Drizzle handles this correctly: on connection loss,
//    .execute() re-prepares automatically on the next call.
//    But the first call after a connection drop pays the full
//    PREPARE + EXECUTE cost again.

// 2. Provisioned concurrency changes the math
//    With provisioned concurrency (Lambda) or always-on instances
//    (Cloud Run min-instances), cold starts drop to ~0%.
//    Prepared statements still help because the connection itself
//    may be new, but the savings shift from "cold start mitigation"
//    to "steady-state optimization."

// 3. Edge runtimes have additional constraints
//    Cloudflare Workers: no persistent connections (use Hyperdrive)
//    Vercel Edge Functions: WebSocket-based drivers only
//    Deno Deploy: limited connection persistence
//
//    In these environments, the connection itself is often more
//    expensive than the query planning. Prepared statements help
//    less when the connection is recreated every invocation.

The connection recycling issue deserves emphasis. AWS Lambda freezes your execution context when the function is idle, preserving the JavaScript heap, the module-scope variables, and the TCP connection to your database. But the TCP connection may be closed by the other end — PostgreSQL's idle_in_transaction_session_timeout, PgBouncer's server_idle_timeout, or AWS's own NAT gateway timeout (approximately 350 seconds). When Lambda thaws your function, the TCP connection is a corpse. node-postgres detects this and creates a new connection, but your prepared statements existed on the old connection. They are gone.

Drizzle handles this gracefully: the next .execute() re-issues the PREPARE on the new connection. But that first request after a connection drop pays the full PREPARE + EXECUTE cost — approximately 1.5ms instead of 0.4ms. In practice, this manifests as occasional latency spikes that are easy to confuse with cold starts. They are not cold starts. They are warm functions with dead connections.

For edge runtimes — Cloudflare Workers, Vercel Edge Functions, Deno Deploy — the calculus changes more fundamentally. These environments often cannot maintain persistent TCP connections at all. Each invocation may establish a new connection (via WebSocket or HTTP proxy), which means every invocation pays the PREPARE cost. In these environments, the prepared statement savings are marginal at best. The connection establishment cost dwarfs the planning cost. If you are deploying to an edge runtime, I would direct your optimization efforts toward connection cost rather than planning cost.

Why Drizzle's architecture makes this particularly effective

Not all ORMs are equally positioned to benefit from prepared statements. The architecture matters, and Drizzle's architecture is unusually well-suited to this optimization.

Drizzle vs Prisma: execution pipeline comparison
// Why Drizzle's approach to prepared statements is different from Prisma:
//
// Prisma: generates SQL at runtime from its query engine (Rust binary)
//   - No .prepare() API exposed to developers
//   - Server-side prepares handled internally, not configurable
//   - Additional overhead: JS -> Query Engine (Rust) -> SQL -> PostgreSQL
//
// Drizzle: compiles SQL at build time, sends directly to PostgreSQL
//   - Explicit .prepare() with named statements
//   - Zero runtime dependencies (no binary, no query engine)
//   - JS -> SQL string -> PostgreSQL (one fewer hop)
//
// Benchmark (Drizzle's own numbers, independently verified):
//   Simple SELECT:   Drizzle 0.41ms  vs  Prisma 0.89ms  (2.2x faster)
//   Complex JOIN:    Drizzle 1.85ms  vs  Prisma 3.40ms  (1.8x faster)
//   INSERT:          Drizzle 0.58ms  vs  Prisma 1.12ms  (1.9x faster)
//
// The difference isn't just prepared statements — it's the entire
// execution pipeline. Drizzle's SQL is a thin string builder.
// Prisma's is a full query engine with serialization overhead.

Drizzle is a thin SQL builder. Your TypeScript code compiles to a SQL string, which is sent directly to PostgreSQL via the driver (node-postgres, postgres.js, or Neon's serverless driver). There is no intermediate query engine, no binary protocol translation, no serialization layer. When you call .prepare(), the generated SQL is sent as a PREPARE command. When you call .execute(), the parameters are sent as an EXECUTE command. The mapping is 1:1.

Prisma operates differently. Your query is translated from the Prisma Client API to an intermediate representation, passed to the Prisma Query Engine (a Rust binary running as a sidecar), which generates SQL that is then sent to PostgreSQL. Prepared statements happen internally within the Query Engine, but the developer has no control over naming, lifetime, or caching behavior. The overhead of the JS-to-Rust serialization roundtrip often exceeds what prepared statement caching saves.

This is not to say Prisma is wrong — its architecture provides other benefits (connection management, schema validation, migration tooling). But for raw query performance, Drizzle's thinner abstraction translates more directly into prepared statement benefits. If you chose Drizzle in part for its performance characteristics, .prepare() is where that choice pays its largest dividend.

How Drizzle compares to Knex and Kysely on prepared statements

Since we are being thorough, the other query builders in the JavaScript ecosystem deserve a comparison. The prepared statement landscape is not uniform, and the differences are instructive.

Drizzle vs Knex vs Kysely: prepared statement support
// Drizzle vs Knex vs Kysely — prepared statement approaches

// Knex: no native .prepare() support
//   Knex generates SQL strings on every call.
//   You can use raw queries with knex.raw() and manually manage
//   prepared statements via the underlying driver, but this defeats
//   the purpose of using a query builder.
//
//   knex.raw('EXECUTE get_user (?)', [userId]);
//   // Works, but you're writing raw SQL anyway.

// Kysely: .compile() but no .prepare()
//   Kysely can compile a query to a SQL string and parameters,
//   but it does not send a PREPARE command to PostgreSQL.
//   It's a build-step optimization, not a database-level one.
//
//   const compiled = db.selectFrom('users')
//     .where('id', '=', userId)
//     .compile();
//   // compiled.sql = "SELECT * FROM users WHERE id = $1"
//   // compiled.parameters = [userId]
//   // But this still gets parsed by PostgreSQL every time.

// Drizzle: true PostgreSQL PREPARE/EXECUTE
//   .prepare() sends PREPARE to PostgreSQL on first execute.
//   Subsequent calls send EXECUTE with parameters only.
//   The database skips parse, analyze, and (usually) plan.
//
//   This is the only approach that saves database-side CPU.
//   Knex and Kysely save JavaScript-side CPU (which is less
//   expensive than database-side CPU in most architectures).

The distinction is important: Knex and Kysely optimize JavaScript-side query construction. Drizzle optimizes database-side query planning. These are different bottlenecks. If your JavaScript query construction is slow (unlikely unless your query builder is doing something extraordinary), Knex and Kysely's approach helps. If your database planning is slow (common for complex queries executed at high frequency), Drizzle's approach helps.

In practice, database-side planning overhead exceeds JavaScript-side construction overhead by 10-50x. Building a SQL string in JavaScript takes microseconds. Planning it in PostgreSQL takes milliseconds. The bottleneck is almost always on the database side, which is why Drizzle's approach — sending a true PREPARE to PostgreSQL — delivers larger improvements than client-side compilation alone.

Monitoring prepared statements in production

Deploying prepared statements without monitoring them is an act of faith. Faith is a fine quality in many contexts. Infrastructure is not one of them. Here are the queries that convert faith into evidence.

Production monitoring queries for prepared statements
-- Production monitoring queries for prepared statements.

-- 1. How many prepared statements exist on each backend?
SELECT pid, count(*) as stmt_count,
       pg_size_pretty(sum(pg_column_size(statement))) as total_sql_size
FROM pg_prepared_statements
GROUP BY pid
ORDER BY stmt_count DESC;

-- Healthy: 10-100 per connection
-- Concerning: 500+ per connection (memory pressure)
-- Dangerous: 1000+ (you likely have a cache leak)

-- 2. Which prepared statements are actually being used?
-- PostgreSQL doesn't track execution count per prepared statement
-- directly, but pg_stat_statements tracks unique query patterns:
SELECT query, calls, mean_exec_time,
       mean_exec_time * calls as total_time_ms
FROM pg_stat_statements
WHERE query LIKE 'EXECUTE%' OR query LIKE '$%'
ORDER BY total_time_ms DESC
LIMIT 20;

-- 3. Memory consumed by prepared statement plans
-- Each prepared statement caches its plan in the backend's
-- private memory. No direct size query exists, but you can
-- estimate from the plan tree:
SELECT name,
       length(statement) as sql_bytes,
       parameter_types
FROM pg_prepared_statements
ORDER BY length(statement) DESC;

-- Rule of thumb: each prepared statement plan consumes 1-50KB
-- of backend memory. At 100 statements per connection and
-- 100 connections: 10-500MB total. Usually negligible, but
-- worth monitoring on memory-constrained instances.

The statement count per connection is the metric that matters most for capacity planning. Each prepared statement consumes memory in the PostgreSQL backend process — the parse tree, the plan tree, and the parameter type metadata. For typical queries, this is 1-50KB per statement. At 100 statements per connection and 100 connections, you are looking at 10-500MB of memory dedicated to cached plans. On a 4GB database instance, this is noticeable. On a 64GB instance, it is irrelevant.

The danger sign is unbounded growth. If your statement count per connection increases over time without plateauing, you have a cache leak — either your application is generating unique statement names (perhaps including a timestamp or request ID in the name, which I have seen in production and regard with the same gentle dismay one reserves for a crumpled napkin), or you are preparing statements inside a loop. Both are bugs. Both are easy to detect with the monitoring queries above. Both are easy to fix.

What if prepared statement promotion happened automatically?

I have one remaining observation, and I hope you will permit me to be direct about it.

The entire exercise we have just walked through — identifying hot queries, adding sql.placeholder(), calling .prepare(), testing PgBouncer compatibility, choosing unique statement names, monitoring generic vs custom plans, handling connection recycling in serverless — is work that your application code should not need to perform. The database knows which queries are executed repeatedly. It knows which ones would benefit from plan caching. The information is right there in pg_stat_statements.

Gold Lapel operates at the proxy layer between your application and PostgreSQL. It maintains an LRU cache of 1,024 prepared statement slots per connection, automatically promoting frequently-executed queries to server-side prepared statements based on observed traffic patterns. Your Drizzle code — with or without .prepare() — benefits from plan caching transparently.

More to the point: Gold Lapel handles the prepared statement lifecycle across pooled connections. The PgBouncer compatibility problem we discussed at length? It does not arise, because the proxy manages statement-to-connection binding internally. Your serverless function sends ordinary queries. The proxy decides which ones to prepare, on which connections, and manages the lifecycle when connections are recycled. The connection recycling edge case? Handled. The statement naming collision risk? Eliminated, because the proxy assigns its own internal names.

I should be clear about what this does not solve. The proxy cannot make your genuinely complex analytical queries faster — those spend their time in execution, not planning. It cannot prepare queries whose structure changes on every invocation. And it adds a network hop — typically 0.1-0.3ms — which offsets some of the planning savings for the simplest queries. For a primary key lookup that saves 0.4ms of planning time, the 0.2ms proxy hop means you net 0.2ms. Still positive, but the margin is thinner.

I would still recommend using Drizzle's .prepare() for your hottest queries — the explicit preparation is marginally faster than proxy-side promotion because it skips the observation window. But for the long tail of queries that are executed frequently enough to benefit from caching but not frequently enough to warrant manual preparation, the proxy handles it. No query changes — just npm install goldlapel and wrap your connection. No placeholder refactoring. No PgBouncer compatibility testing.

Prepare the queries you know about. Let the infrastructure handle the ones you do not.

Frequently asked questions

Terms referenced in this article

The Bun runtime, if you happen to be considering it, introduces its own PostgreSQL performance characteristics worth understanding. I have written a Bun ORM PostgreSQL performance analysis that benchmarks Drizzle on Bun against Node and Deno, with the prepared statement behavior differences that matter in production.