Back to posts

The Silent Data Loss Bug: How Prepared Statements Break PostgreSQL Connection Pooling

Read the full guide on docs.beyondyou.my.id
postgresqlpgbouncerconnection-poolingprepared-statementsdatabasebackenddevops

The Silent Data Loss Bug: How Prepared Statements Break PostgreSQL Connection Pooling

Table of Contents

SectionTopicDescription
01The Scalability DilemmaWhy scaling PostgreSQL hits a wall.
02Connection AnatomyProcess-based vs thread-based, RAM cost.
03The Connection BottleneckWhy max_connections=100 is a trap.
04PgBouncer to the RescueSession mode vs transaction mode.
05The Prepared Statement TrapHow your ORM silently breaks data.
06The FixWhat to change and when.

1. The Scalability Dilemma

You’re scaling horizontally. Pods go from 2 to 20. Auto-scalers kick in. Serverless functions spin up on demand. Everything looks great — until your PostgreSQL database throws:

FATAL: too many connections for role "appuser"

Your database didn’t suddenly get more traffic. Your connection count did. And that’s the problem most teams discover too late.


2. Connection Anatomy

PostgreSQL: Process-per-Connection

PostgreSQL uses fork() — the OS creates a new process for every client connection. Not a thread. A full process.

// Simplified: what happens when you connect
socket = accept(server_fd);
pid = fork();  // Full process copy
if (pid == 0) {
    handle_client(socket);  // Child process handles this connection
}
AspectPostgreSQL (Process)MySQL (Thread)
ModelProcess-per-connectionThread-per-connection
RAM per connection10-16 MB~1 MB
IsolationFull (separate memory space)Shared (thread-local storage)
Crash impactOne connection crashes, others surviveThread crash can affect others
OS overheadHigh (process context switch)Low (thread context switch)

Why Process, Not Thread?

PostgreSQL was designed in the 1990s when threads were unreliable across platforms. The fork() model provides:

  • Memory safety — each connection has its own address space
  • Crash isolation — a bad query can’t corrupt another connection’s state
  • Simplicity — no locking primitives needed for connection-local data

The tradeoff: RAM is expensive.

Real-World RAM Cost

# Check PostgreSQL memory usage per connection
SELECT
    pid,
    usename,
    state,
    pg_size_pretty(pg_relation_size(0)) as mem_usage
FROM pg_stat_activity;
WorkloadRAM per Connection
Idle connection~5 MB
Simple SELECT~10 MB
Complex JOIN + sorting~16 MB
Large result set with COPY~50 MB+

3. The Connection Bottleneck

The Math That Kills You

Default max_connections = 100
RAM per connection = 10 MB (average)
Total RAM for connections = 100 × 10 MB = 1 GB

That’s 1 GB of RAM just for connection overhead — before your application even runs a query.

When Auto-Scaling Breaks

ScenarioPods/InstancesConnections per PodTotal Connections
Normal21020
Traffic spike1010100
Auto-scale2010200 → FAIL
Serverless (50 concurrent)50150 → OK, but

With 20 pods × 10 connections = 200 connections, but max_connections=100. Your database refuses connections.

The Serverless Paradox

Serverless functions (Lambda, Cloud Run, Vercel) each open a new connection. With 50 concurrent invocations:

  • 50 connections × 10 MB = 500 MB just for connections
  • Each function runs for 100ms, but the connection lives longer
  • Connection pool exhaustion happens in seconds

4. PgBouncer to the Rescue

PgBouncer is a lightweight connection pooler that sits between your application and PostgreSQL. It maintains a pool of actual database connections and multiplexes client connections across them.

graph LR
    subgraph APP["Application"]
        c1["Client 1"]
        c2["Client 2"]
        c3["Client 3"]
        c4["Client 4"]
        c5["Client 5"]
    end

    subgraph PGBOUNCER["PgBouncer Pool"]
        p1["Pool Slot 1"]
        p2["Pool Slot 2"]
        p3["Pool Slot 3"]
    end

    subgraph PG["PostgreSQL"]
        db1["Process 1"]
        db2["Process 2"]
        db3["Process 3"]
    end

    c1 --> p1
    c2 --> p1
    c3 --> p2
    c4 --> p2
    c5 --> p3
    p1 --> db1
    p2 --> db2
    p3 --> db3

5 clients → 3 database connections. RAM saved: 20 MB.

Session Mode vs Transaction Mode

AspectSession ModeTransaction Mode
Connection bindingClient gets a dedicated DB connection for entire sessionClient gets a DB connection only during a transaction
RAM efficiencyLow (1:1 mapping while connected)High (N:1 multiplexing)
Feature supportFull (PREPARE, LISTEN/NOTIFY, SET)Limited (no session-level state)
Use caseLong-running apps, psqlServerless, auto-scaling, connection-heavy
Pool size neededHighLow

PgBouncer Configuration

[databases]
appdb = host=example-rds.xxxx.ap-southeast-3.rds.amazonaws.com port=5432 dbname=appdb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
server_lifetime = 3600
server_idle_timeout = 600
client_idle_timeout = 0

5. The Prepared Statement Trap

Here’s where it gets dangerous. Your ORM or database driver might be silently breaking your data.

What Are Prepared Statements?

-- Step 1: Prepare (creates a named statement on the connection)
PREPARE user_insert AS
  INSERT INTO users (name, email) VALUES ($1, $2);

-- Step 2: Execute (runs the prepared statement)
EXECUTE user_insert('Alice', 'alice@example.com');

Prepared statements are bound to a specific database connection. They exist in the server’s memory space for that connection.

The Transaction Mode Problem

In PgBouncer Transaction Mode:

  1. App sends PREPARE user_insert → PgBouncer routes to Connection A
  2. Transaction ends → PgBouncer releases Connection A back to pool
  3. App sends EXECUTE user_insert → PgBouncer routes to Connection B
  4. Connection B doesn’t have user_insert prepared
ERROR: prepared statement "user_insert" does not exist

The Intermittent Nature

This bug is intermittent because:

  • It only happens when the pool reassigns connections between prepare and execute
  • Under low load, the same connection might be reused (bug doesn’t appear)
  • Under high load, connections are reassigned frequently (bug appears)
Load LevelConnection ReuseBug Frequency
Low (dev)HighRare (never seen in dev)
Medium (staging)MediumIntermittent
High (prod)LowFrequent

The Silent Data Loss

The worst part: sometimes the error is swallowed by the application’s error handling. The INSERT appears to succeed (HTTP 200), but the data is never committed because the execute failed.

// Application code
try {
  const user = await db.insert(users).values({ name: 'Alice' });
  // user.id exists — but was it actually committed?
  return res.json({ id: user.id });  // HTTP 200
} catch (error) {
  // Error swallowed by generic handler
  return res.json({ id: user.id });  // Still HTTP 200, but data lost
}

Which ORMs/Drivers Are Affected?

Driver/ORMPrepared Statement DefaultStatus
Bun (bun:postgres)Enabled⚠️ Dangerous in Transaction Mode
Drizzle ORMEnabled⚠️ Dangerous in Transaction Mode
node-postgres (pg)Disabled by default✅ Safe
PrismaDisabled by default✅ Safe
SQLAlchemyDisabled by default✅ Safe
DjangoDisabled by default✅ Safe

PgBouncer 1.21.0+ Fix

PgBouncer 1.21.0 introduced max_prepared_statements — protocol-level tracking that maps prepared statements across connections:

[pgbouncer]
pool_mode = transaction
max_prepared_statements = 100

But this adds overhead and isn’t available on all managed PgBouncer services.


6. The Fix

Decision Matrix

Application TypePool ModePrepared StatementsWhy
Long-running (K8s pod)Session ModeKeep enabledFull feature support
Serverless (Lambda, Cloud Run)Transaction ModeMust disableConnection per invocation
Auto-scale (HPA)Transaction ModeMust disableHigh connection churn
Connection pooler (PgBouncer 1.21+)Transaction ModeOptional (with tracking)New feature, test first

Disable Prepared Statements

Node.js / Bun (pg driver):

// Connection string
DATABASE_URL=postgresql://user:pass@host:5432/db?prepared=false

// Or in config
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  prepareThreshold: 0  // Disable prepared statements
});

Drizzle ORM:

import { drizzle } from 'drizzle-orm/node-postgres';

const db = drizzle(process.env.DATABASE_URL, {
  prepareThreshold: 0  // Disable prepared statements
});

Bun (bun:postgres):

import { PostgresClient } from 'bun';

const client = new PostgresClient({
  url: process.env.DATABASE_URL,
  prepareThreshold: 0  // Disable prepared statements
});

Prisma:

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // Prepared statements are disabled by default in Prisma
  // No changes needed
}

Python (SQLAlchemy):

from sqlalchemy import create_engine

engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
    # Prepared statements disabled by default
)

Checklist

ItemSession ModeTransaction Mode
pool_modesessiontransaction
prepared_statementsEnabledDisabled
max_client_connMatch app connections10x default_pool_size
default_pool_sizeMatch DB max_connections20-50
server_lifetime36003600
reserve_pool_size55
Application connection_limit10-203-5

References