PERFORMANCE

PostgreSQL Connection Exhaustion: How to Fix 'Too Many Connections' with PgBouncer

By DharmOps TeamMay 16, 202612 min read
PostgreSQL connection pool exhaustion and PgBouncer architecture diagram
On this page

' Every new database connection attempt fails. Your application returns 500 errors or hangs waiting for a connection to become available.

The on-call engineer gets paged, checks the database server — CPU at 20%, memory fine, disk fine — and wonders why PostgreSQL is refusing connections when it appears perfectly healthy. The answer lies in how PostgreSQL handles connections at the architecture level.

Unlike MySQL's thread-based model, PostgreSQL spawns a dedicated OS process for every database connection. At 300 connections, you have 300 server processes running.

At 500 connections, you are consuming approximately 5 GB of RAM purely in connection overhead — before a single query runs. The max_connections limit exists because the server will degrade severely and eventually crash if allowed to spawn unlimited processes.

PgBouncer solves this by sitting as a lightweight proxy between your application and PostgreSQL, multiplexing hundreds of application connections onto a small, stable pool of actual database connections. This guide covers why the problem occurs, how to diagnose it, and how to deploy PgBouncer correctly.

Getting connection errors right now? Book a free 30-min diagnostic →

Why PostgreSQL's Connection Architecture Creates This Problem

PostgreSQL uses a process-per-connection model: each client connection maps to a dedicated backend process on the server. This design gives PostgreSQL strong isolation and simplifies the kernel scheduling model, but it imposes a real cost at connection scale. Each backend process consumes approximately 5–10 MB of memory at idle, plus the process spawn overhead when the connection is first established.

At 200 connections, that baseline is 1–2 GB. 5–5 GB — purely from connection overhead, not from any query work. The max_connections default of 100 in PostgreSQL exists because the process-per-connection model genuinely degrades server performance beyond a few hundred connections.

The typical production recommendation is to keep max_connections under 200–400 depending on instance memory and RAM, and to use connection pooling for everything beyond that. The problem becomes acute with modern application deployment patterns: containerised microservices each maintaining their own connection pools, serverless functions establishing new connections on every invocation, and ORM frameworks with connection pools configured independently per application instance. A Kubernetes deployment with 20 application pods, each running a connection pool of 10, is attempting to maintain 200 permanent connections against PostgreSQL before any user traffic arrives.

-- Check current connection count and limits
SELECT
  max_conn,
  used,
  res_for_super,
  max_conn - used - res_for_super AS available
FROM (
  SELECT
    (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_conn,
    count(*) AS used,
    (SELECT setting::int FROM pg_settings WHERE name = 'superuser_reserved_connections') AS res_for_super
  FROM pg_stat_activity
) t;

-- See active connections by application and state
SELECT
  application_name,
  state,
  count(*)
FROM pg_stat_activity
WHERE state IS NOT NULL
GROUP BY application_name, state
ORDER BY count DESC;

How to Confirm Connection Exhaustion Is Your Problem

Before deploying PgBouncer, confirm that connection exhaustion is actually the issue. The clearest signal is the FATAL error in your application logs combined with a connection count approaching max_connections in pg_stat_activity. Check the CloudWatch DatabaseConnections metric if you are on RDS — a flat line at or near max_connections during incident periods is diagnostic.

Check your application connection pool metrics: if the pool is at maximum size and connections are queuing, the pool is exhausted. Examine the wait_event and wait_event_type columns in pg_stat_activity: connections waiting on ClientRead are idle connections held open by the application; a large number of these means your application is holding connections longer than necessary. Also check for connections in the 'idle in transaction' state — these are connections inside an open transaction that is not currently executing a query.

A large count of idle-in-transaction connections indicates the application is not committing or rolling back transactions promptly, holding connections and locks simultaneously.

-- Connections by state — look for large 'idle' and 'idle in transaction' counts
SELECT
  state,
  wait_event_type,
  wait_event,
  count(*) AS conn_count
FROM pg_stat_activity
WHERE pid != pg_backend_pid()
GROUP BY state, wait_event_type, wait_event
ORDER BY conn_count DESC;

-- Connections idle for longer than 5 minutes (candidates for pgbouncer's server_idle_timeout)
SELECT
  pid,
  application_name,
  state,
  now() - state_change AS idle_duration,
  usename
FROM pg_stat_activity
WHERE state IN ('idle', 'idle in transaction')
  AND now() - state_change > INTERVAL '5 minutes'
ORDER BY idle_duration DESC;

Related: why your API is slow even when the database looks fine, the N+1 query problem

What PgBouncer Does and the Three Pooling Modes

PgBouncer is a lightweight connection pooler that sits between your application and PostgreSQL. Your application connects to PgBouncer as if it were the database. PgBouncer maintains a small pool of actual PostgreSQL backend connections and multiplexes application connections across them.

When an application connection sends a query, PgBouncer assigns it a free backend connection from the pool, routes the query, and returns the backend connection to the pool when done. From PostgreSQL's perspective, it sees only the pooler's N backend connections — never the potentially hundreds of application connections behind it. PgBouncer supports three pooling modes with different trade-offs.

Session pooling assigns a backend connection to each application connection for its entire lifetime — effectively the same as direct PostgreSQL connections, with no multiplexing benefit during query execution. Transaction pooling assigns a backend connection for the duration of each transaction. This is the highest-multiplexing mode and what most teams need: a backend connection is only held while work is in progress, not during the time between transactions when the application connection is idle.

Statement pooling assigns a connection per individual statement — incompatible with multi-statement transactions and rarely used. For most production workloads, transaction pooling with a pool_size of 10–20 backend connections per database-user pair handles hundreds of application connections with no connection errors.

Step-by-Step: Installing and Configuring PgBouncer

PgBouncer is a single binary with a plain-text configuration file. On Debian/Ubuntu, install with apt. On Amazon Linux, use yum or compile from source.

txt (authentication credentials). ini [databases] section defines connection targets — each entry maps a logical database name (what the application connects to) to the actual PostgreSQL connection parameters. The [pgbouncer] section sets the listen address and port, the pooling mode, pool sizing, authentication method, and operational limits.

Place PgBouncer on the same server as your application (sidecar pattern) or on a dedicated proxy host in the same subnet as PostgreSQL. For Kubernetes deployments, running PgBouncer as a sidecar container in the application pod eliminates network latency and keeps the proxy close to the consumer. For RDS, PgBouncer on an EC2 instance or ECS task in the same VPC and subnet achieves sub-millisecond proxy overhead.

# pgbouncer.ini — transaction pooling configuration
[databases]
# Maps logical name → actual PostgreSQL connection
myapp = host=prod-rds.cluster.us-east-1.rds.amazonaws.com port=5432 dbname=myapp

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 5432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

# Pooling mode
pool_mode = transaction

# Core sizing
max_client_conn = 2000        # max application connections to PgBouncer
default_pool_size = 20        # backend connections per db-user pair
min_pool_size = 5             # keep N connections warm at all times
reserve_pool_size = 5         # extra connections for spikes
reserve_pool_timeout = 3      # seconds to wait before using reserve pool

# Timeouts
server_idle_timeout = 600     # close idle backend connections after 10 min
client_idle_timeout = 0       # never close idle client connections
query_wait_timeout = 30       # queue application queries for up to 30 sec
server_connect_timeout = 15   # fail fast if PostgreSQL is unreachable

# Logging
log_connections = 0           # disable in production (verbose)
log_disconnections = 0
stats_period = 60

# userlist.txt (scram-sha-256 or md5 passwords)
# "username" "password"

Calculating the Right pool_size

The correct pool_size is not a fixed number — it depends on your PostgreSQL instance's CPU count, your workload's query duration, and your target concurrency. The formula commonly used is: pool_size = (CPU cores × 2) + effective_io_concurrency. 2xlarge with 8 vCPUs, that gives a baseline of 17–24 backend connections per database-user pair.

In practice, start at 15–20 for OLTP workloads where queries complete in milliseconds, and benchmark upward. The pool_size determines how many queries can execute simultaneously against PostgreSQL. If you set it too low, application connections queue behind PgBouncer waiting for a free backend slot — you trade connection errors for queue latency.

If you set it too high, you push PostgreSQL's backend process count past its efficient operating range. Monitor the PgBouncer stats table (SHOW STATS, SHOW POOLS) to see average query wait time and pool utilisation. If cl_waiting (clients waiting for a pool connection) is consistently above zero, increase pool_size.

If sv_idle (idle backend connections) is consistently high relative to sv_used, decrease pool_size.

-- Connect to PgBouncer admin interface and check pool status
-- psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer

-- See pool utilisation per database-user pair
SHOW POOLS;
-- Key columns:
-- cl_active: clients connected and executing
-- cl_waiting: clients queued waiting for a server connection (want this at 0)
-- sv_active: server connections in use
-- sv_idle: server connections idle (available)
-- sv_used: server connections recently used

-- Overall stats
SHOW STATS;
-- avg_query_time: average query duration through the pooler (microseconds)
-- avg_wait_time: average time clients spent waiting for a server connection
-- total_query_count: total queries routed since last reset

Related: our AWS RDS performance tuning guide

Serverless Functions and Connection Exhaustion — A Special Case

Serverless functions (AWS Lambda, Vercel Edge Functions, Cloudflare Workers) are the worst-case scenario for direct PostgreSQL connections. Each function invocation establishes a new connection because there is no persistent process between invocations to maintain a connection pool. A Lambda function that executes 500 times per minute creates up to 500 connection establishment attempts per minute.

Even with connection reuse across warm invocations — which is unreliable and depends on invocation frequency — the connection count scales linearly with concurrent executions. For Lambda and similar runtimes, two solutions exist: AWS RDS Proxy (a managed PgBouncer equivalent integrated with IAM authentication, useful when you are already on RDS and want zero infrastructure to manage) and a PgBouncer instance on a persistent compute layer (EC2, ECS, or a small container) that the serverless functions connect to. 015 per vCPU-hour on the underlying instance.

small EC2 instance costs approximately $15/month and handles tens of thousands of application connections with sub-millisecond proxy overhead.

# AWS RDS Proxy — managed connection pooling for Lambda
# Create via AWS CLI (requires IAM role and Secrets Manager secret)
aws rds create-db-proxy \
  --db-proxy-name myapp-proxy \
  --engine-family POSTGRESQL \
  --auth '[{"AuthScheme":"SECRETS","SecretArn":"arn:aws:secretsmanager:...","IAMAuth":"REQUIRED"}]' \
  --role-arn arn:aws:iam::123456789:role/rds-proxy-role \
  --vpc-subnet-ids subnet-abc123 subnet-def456 \
  --vpc-security-group-ids sg-abc123

# Lambda environment: connect to Proxy endpoint (not RDS directly)
# DB_HOST = myapp-proxy.proxy-abc123.us-east-1.rds.amazonaws.com

# For self-managed PgBouncer on EC2:
# Application → EC2 PgBouncer (port 5432) → RDS PostgreSQL
# PgBouncer maintains 20 backend connections
# Lambda can have 500+ concurrent connections to PgBouncer safely

Not sure whether you need RDS Proxy or self-managed PgBouncer?

A senior DBA can size the right pooling setup for your traffic pattern and deploy it — free, 30 minutes to start.

Book Free Diagnostic

Monitoring PgBouncer in Production

PgBouncer exposes operational metrics through its admin interface — a virtual database named pgbouncer accessible on the pooler's listen port. SHOW POOLS gives per-pool utilisation with client and server connection counts split by state. SHOW STATS gives aggregate throughput metrics including average query time, average wait time, and total requests.

SHOW CLIENTS and SHOW SERVERS give per-connection detail useful during incident investigation. For continuous monitoring, pgbouncer_exporter is a Prometheus exporter that scrapes the admin interface and exposes all metrics for Grafana dashboards. The three metrics that matter most in production: cl_waiting (should be zero or near-zero at steady state), avg_wait_time (should stay under 5ms for OLTP workloads), and sv_idle (should stay above zero — if it reaches zero, increase pool_size before clients start queuing).

Set alerting on cl_waiting greater than 10 for more than 30 seconds — that is the leading indicator of connection starvation before it manifests as application errors.

-- Prometheus alerting rules for PgBouncer
# Alert if clients are waiting (connection starvation starting)
- alert: PgBouncerClientsWaiting
  expr: pgbouncer_pools_cl_waiting > 5
  for: 30s
  labels:
    severity: warning
  annotations:
    summary: "PgBouncer clients waiting for connections"
    description: "{{ $value }} clients waiting in pool {{ $labels.database }}/{{ $labels.user }}"

# Alert if average wait time exceeds 10ms
- alert: PgBouncerHighWaitTime
  expr: pgbouncer_stats_avg_wait_time_seconds > 0.01
  for: 1m
  labels:
    severity: warning

# Alert if no idle server connections (pool fully saturated)
- alert: PgBouncerPoolSaturated
  expr: pgbouncer_pools_sv_idle == 0 and pgbouncer_pools_sv_active > 0
  for: 30s
  labels:
    severity: critical

Related: our database troubleshooting service

PostgreSQL connection exhaustion is one of the most disruptive production database incidents — and one of the most preventable. The root cause is rarely the database itself and almost always connection management at the application layer: too many persistent connections from too many application instances, serverless functions creating connections per invocation, or ORM pools configured independently per pod. PgBouncer solves this with minimal operational overhead: a single binary, a simple configuration file, and transaction pooling mode that multiplexes hundreds of application connections onto 15–25 backend connections.

Most teams can go from connection errors to stable production in an afternoon. The key configuration decisions are pool_mode = transaction, default_pool_size sized to 2× your vCPU count, and monitoring on cl_waiting as the leading indicator of pool saturation. If you are on RDS and want managed pooling without running your own infrastructure, RDS Proxy is the AWS-native equivalent — slightly more expensive, zero infrastructure to manage.

Frequently Asked Questions

What causes 'too many clients already' in PostgreSQL?

PostgreSQL uses a process-per-connection model — each connection spawns a dedicated OS process consuming 5–10 MB of memory. When connections reach the max_connections limit (default 100), PostgreSQL rejects all new connection attempts with 'FATAL: sorry, too many clients already.' The fix is connection pooling with PgBouncer, which multiplexes hundreds of application connections onto a small pool of actual backend connections that PostgreSQL can handle.

What is PgBouncer and how does it work?

PgBouncer is a lightweight connection pooler that sits between your application and PostgreSQL. Applications connect to PgBouncer as if it were the database. PgBouncer maintains a small pool of real PostgreSQL connections and routes queries from application connections through them. From PostgreSQL's perspective, it only sees the pooler's connections — never the potentially hundreds of application connections behind it.

Should I use session mode or transaction mode in PgBouncer?

Use transaction pooling (pool_mode = transaction) for most production workloads. Transaction mode assigns a backend connection only for the duration of each transaction, not the entire application session — providing the highest multiplexing ratio. Session mode holds a backend connection for the full application session lifetime, providing no multiplexing benefit. Avoid statement mode unless your workload uses no multi-statement transactions.

What pool_size should I set in PgBouncer?

Start with pool_size = (CPU cores × 2) + effective_io_concurrency. For a db.r6g.2xlarge with 8 vCPUs, that is 17–24 backend connections. Monitor cl_waiting in SHOW POOLS — if it is consistently above zero, increase pool_size. If sv_idle is consistently high, decrease it. For most OLTP workloads, 15–20 backend connections per database-user pair handles hundreds of application connections without errors.

Does AWS RDS Proxy replace PgBouncer?

AWS RDS Proxy is a managed connection pooler built for RDS that requires no self-managed infrastructure. It adds 2–5ms overhead per query and costs $0.015 per vCPU per hour. Self-managed PgBouncer on an EC2 instance costs approximately $15/month with sub-millisecond overhead. Use RDS Proxy for Lambda and serverless workloads where zero infrastructure management matters. Use PgBouncer for non-serverless applications where latency and cost efficiency are priorities.

Need Expert Database Guidance?

Book a free 30-minute diagnostic call. Whether you are debugging slow queries, evaluating databases, or planning a migration — we will give you specific, actionable recommendations, not generic advice.

BOOK DIAGNOSTIC

Related Articles

How to Debug Slow SQL Queries: A Step-by-Step Guide

February 18, 2026

How to Debug Slow SQL Queries: A Step-by-Step Guide

Learn how to find, diagnose, and fix slow SQL queries using EXPLAIN ANALYZE, slow query logs, and proven debugging techniques. Covers missing indexes, N+1 patterns, full table scans, and query rewriting — with real SQL examples throughout.

Read more
PostgreSQL vs MySQL in 2026: A Complete Guide to Choosing the Right Database

February 25, 2026

PostgreSQL vs MySQL in 2026: A Complete Guide to Choosing the Right Database

A complete PostgreSQL vs MySQL comparison for 2026. Performance benchmarks, JSON support, replication models, licensing, migration complexity, and a practical decision framework for CTOs, architects, and developers.

Read more
Database Backup and Recovery: A Complete Best Practices Guide

March 3, 2026

Database Backup and Recovery: A Complete Best Practices Guide

Everything you need to build a complete database backup strategy: full vs incremental vs differential backups, PITR setup, RTO/RPO planning, tool comparison (pg_basebackup, WAL-G, Barman), recovery testing procedures, and compliance retention requirements.

Read more
AWS RDS Performance Tuning: A Complete Optimization Guide

March 5, 2026

AWS RDS Performance Tuning: A Complete Optimization Guide

How to get maximum performance from AWS RDS: choosing the right instance class, tuning PostgreSQL parameter groups, configuring read replicas, optimising GP3 storage and IOPS, setting up CloudWatch and Performance Insights, cutting costs, and when to migrate to Aurora.

Read more
AWS RDS Extended Support Is Doubling in 2026 — Here's What You Owe and How to Stop It

May 12, 2026

AWS RDS Extended Support Is Doubling in 2026 — Here's What You Owe and How to Stop It

MySQL 5.7 and PostgreSQL 11 on RDS entered Extended Support Year 3 in 2026, and AWS doubled the rate on March 1. Most teams don't know what they're paying or how to calculate their exposure. This guide shows exactly what the charges are, how to find them in your bill, and the three upgrade paths that eliminate the cost.

Read more
Your API Is Slow. Your Database Probably Isn't the Problem.

May 19, 2026

Your API Is Slow. Your Database Probably Isn't the Problem.

When an API endpoint starts crawling, the database gets blamed first. Most of the time, it's wrong — 80% of API latency problems trace to the application layer. This guide shows you how to isolate where the latency actually lives, the four most common app-layer causes, and how to fix each one. Includes a real case: p99 from 4.2 seconds to 180ms.

Read more
PostgreSQL 18: Every Performance Improvement You Need to Know

May 22, 2026

PostgreSQL 18: Every Performance Improvement You Need to Know

PostgreSQL 18 entered public beta in April 2026 with meaningful performance gains: 20–30% short query throughput improvements, major vacuum and autovacuum changes, an enhanced query planner, reliable logical replication, and pgvector improvements for AI workloads. Here is what changed, what it means in practice, and how to test it before upgrading production.

Read more
What Is the N+1 Query Problem? (And Why It Kills APIs)

June 16, 2026

What Is the N+1 Query Problem? (And Why It Kills APIs)

Your API takes four seconds to load. The database CPU looks healthy. You add a read replica — nothing changes. You upgrade the instance — still slow. Someone profiles the request and finds one API call is generating 501 database queries. This is the N+1 query problem: one of the most common and expensive performance issues in production applications. It doesn't look like a bug, throws no errors, and hides inside clean ORM code. This guide covers what it is, why it happens, how to detect it in production, and five ways to fix it permanently.

Read more
pgvector vs Pinecone in 2026: Benchmarks, Cost, and When to Use Each

June 16, 2026

pgvector vs Pinecone in 2026: Benchmarks, Cost, and When to Use Each

Most teams evaluating vector search assume a serious AI application needs a dedicated vector database. That assumption is increasingly outdated. pgvector's HNSW indexing, PostgreSQL 18's throughput gains, and managed PostgreSQL offerings have shifted the decision boundary. This guide covers where the performance gap actually stands, cost at three workload sizes, the hybrid SQL+vector search advantage most comparisons miss, and the exact conditions where Pinecone becomes the right call.

Read more
The Database FinOps Guide: Stop Overpaying for Cloud Databases

June 16, 2026

The Database FinOps Guide: Stop Overpaying for Cloud Databases

Cloud database bills grow in ways that are hard to see until the number is already embarrassing. Overprovisioned instances, idle read replicas, storage you forgot was accumulating, queries running full table scans on every request — each one adds a line item that compounds quietly. This guide walks through what Database FinOps actually means, the seven biggest sources of database waste, the four-step FinOps process, and a realistic SaaS example that cut $36,000 per year without touching the application code.

Read more
Managed DBA vs. Hiring In-House: The Full Cost Breakdown for 2026

May 26, 2026

Managed DBA vs. Hiring In-House: The Full Cost Breakdown for 2026

Most engineering leaders underestimate the true cost of an in-house DBA by 40–60% because they think salary, not total cost. This guide breaks down the full economics of both models, the scenarios where each wins, and a side-by-side comparison across four company sizes — from a 15-person startup to a 400-person scale-up.

Read more
Why Is My AWS RDS Bill So High? A Diagnostic Guide

June 16, 2026

Why Is My AWS RDS Bill So High? A Diagnostic Guide

Your AWS RDS bill is high for a reason — usually several. This guide covers the seven most common causes: overprovisioned compute, forgotten read replicas, inefficient queries, storage waste, gp2 storage, on-demand pricing, and solving the wrong problem entirely. Includes CloudWatch diagnostic commands, pg_stat_statements queries, and a 15-minute audit checklist.

Read more