PostgreSQL Connection Pool Sizing: A Practical Guide for Web Apps
Learn how to size PostgreSQL application pools and PgBouncer budgets without exhausting connections or hiding database bottlenecks.
Most PostgreSQL connection problems in web applications are not caused by one bad query. They come from a mismatch between how many clients the application can open and how many sessions the database can run well. A service with ten containers, twenty worker threads per container, background jobs, migrations, and preview environments can accidentally ask PostgreSQL for hundreds of connections before traffic is large enough to justify them.
Connection pooling fixes part of the problem, but only when the pool is sized deliberately. A pool that is too small creates unnecessary waiting in the application. A pool that is too large moves the queue into PostgreSQL, where memory, lock waits, and context switching get worse. The useful goal is not "more connections." It is enough active database work to keep the database busy, plus clear backpressure everywhere else.
This deep dive explains how to size PostgreSQL pools for production web apps, how PgBouncer changes the math, and what to measure before increasing limits.
Why connection count is a capacity decision
Every PostgreSQL client connection is a server-side session. PostgreSQL exposes a max_connections setting, reserves some slots for superusers and maintenance, and documents that increasing connection limits can require more shared memory and kernel resources. Raising the number is therefore not a harmless application setting. It changes the amount of database concurrency the system is willing to accept.
The important distinction is sessions versus useful work. A web framework may keep many sessions open because idle workers want a ready connection. PostgreSQL only benefits from connections that are doing productive work, and even then only up to the point where CPU, I/O, locks, or memory become the bottleneck. After that point, extra connections mostly add queueing and overhead.
This is why managed PostgreSQL setups often include PgBouncer. A pooler lets many application clients share a smaller number of PostgreSQL server connections. It can also give applications a stable endpoint while the database keeps a more controlled concurrency level. The pooler does not make heavy queries cheap, and it does not remove locks, but it gives you a better place to queue.
Start with the workload shape
Pool sizing begins with the way the application uses the database, not with the biggest number a plan allows. A request-driven API with short indexed queries needs different settings from a reporting service that runs long aggregations. A background worker that processes jobs in parallel needs a different budget from a migration runner that should usually be alone.
A practical inventory includes each service that can connect to production: web containers, worker containers, scheduled jobs, admin consoles, migration tools, and read replicas if the application has separate read paths. For each service, count maximum process concurrency, per-process pool size, and whether connections are held for a whole request or only around short database operations. Multiplying container count by application pool size is often the moment when teams discover why production is hitting connection limits.
The goal is to choose where waiting should happen. If the web tier accepts 1,000 simultaneous requests but the database can only run 40 useful queries at a time, some layer must queue. It is usually better for the application pool or PgBouncer to queue briefly with clear timeouts than for PostgreSQL to admit hundreds of active sessions and let the whole system slow down unpredictably.
A sizing model that works in practice
A simple starting model is to reserve database connections by purpose. Keep headroom for maintenance and emergency access, give the main application a controlled budget, and keep background jobs from consuming the same slots that user-facing requests need. Then set application pools small enough that the combined maximum cannot exceed the PgBouncer or PostgreSQL budget.
The table below is not a universal formula, but it is a useful design pattern for a small production SaaS application.
| Connection budget | Typical use | Sizing note |
|---|---|---|
| Reserved headroom | admin access, monitoring, backups, urgent fixes | Keep outside normal app pools so incidents are debuggable |
| Web requests | short transactions and user-facing queries | Size for steady concurrency, not total HTTP request concurrency |
| Background workers | queues, emails, imports, recurring jobs | Cap separately so batch work cannot starve the app |
| Migrations and maintenance | DDL, data backfills, one-off scripts | Run with an explicit low pool and scheduled windows |
| Analytics or reporting | slower reads and exports | Prefer separate paths or replicas when queries are long-lived |
For an application using PgBouncer in transaction pooling mode, the app may open more client connections to PgBouncer than PgBouncer opens to PostgreSQL. The server-side pool should still be conservative. If each request spends only a small fraction of its time inside a database transaction, multiplexing can absorb bursts without assigning a PostgreSQL backend to every waiting HTTP request.
Without PgBouncer, each application pool connection maps more directly to a PostgreSQL session. In that case, total application pool capacity should be safely below max_connections after subtracting reserved slots and non-application users. If five web containers each have a pool of 20, the application can already open 100 sessions before workers and maintenance are counted.
PgBouncer does not remove application pool sizing
PgBouncer changes where sessions are pooled, but it does not mean every application pool can be unlimited. If the application opens too many client connections to PgBouncer, the pressure moves to the pooler. That can still create timeouts, memory use, and confusing incident symptoms. The clean design is a two-layer budget: application pools protect each process, and PgBouncer protects PostgreSQL.
PgBouncer's pooling mode matters. Session pooling keeps a server connection assigned to a client for the life of the client session, which is compatible with more session features but provides less multiplexing. Transaction pooling releases the server connection after each transaction, which is often the best fit for web applications with short transactions. Statement pooling is more restrictive and is rarely the default choice for application frameworks.
Transaction pooling also requires application awareness. Session-level state, long transactions, advisory patterns, temporary tables, and prepared statement behavior can surprise teams if they assume a client always returns to the same server connection. If your app depends on session state, test it before changing pooling modes. ArmorDB's PgBouncer guide is a useful companion when deciding which mode fits your workload.
Measure before raising limits
When a service reports connection errors, the tempting fix is to raise pool sizes. That can help when the pool is genuinely too small and the database has idle capacity. It can hurt when the database is already saturated or blocked. Before changing numbers, look at what sessions are doing.
PostgreSQL's statistics views are the right starting point. pg_stat_activity shows whether sessions are active, idle, waiting on locks, or idle inside a transaction. Wait events show whether the problem is CPU, I/O, locks, client reads, or something else. If most sessions are idle, raising max_connections is unlikely to improve throughput. If many sessions are active and slow, query plans, indexes, or workload concurrency may be the real issue. If many sessions are waiting on locks, more connections can make the queue longer without increasing useful work.
Application metrics complete the picture. Track pool checkout latency, pool timeouts, request latency, and the number of open client connections per process. PgBouncer metrics such as client wait counts and server pool usage tell you whether the pooler is providing backpressure or constantly saturated. The healthiest systems show short bursts of waiting during traffic spikes and quick recovery when the spike ends.
A practical rollout plan
For a new production app, start smaller than your instinct suggests. Set each web process to a modest pool, cap worker pools separately, and leave visible headroom in PostgreSQL. Load test the main user journeys with realistic request concurrency, then increase the budget only where the database has capacity and application wait time is the bottleneck.
For an existing app with connection incidents, first calculate the theoretical maximum. Count every deployment replica and every process pool. Then compare that number with the database limit and PgBouncer server pool. If the theoretical maximum is far above the database budget, reduce application pools or add PgBouncer before raising max_connections. If the maximum is below the limit but requests still wait, inspect active queries, locks, and transaction duration.
During changes, keep timeouts explicit. Application pool acquisition timeouts should fail requests quickly enough to protect the service. PostgreSQL statement_timeout and idle_in_transaction_session_timeout can prevent abandoned work from holding resources too long. These are guardrails, not substitutes for good sizing, but they turn mysterious slowdowns into errors that can be observed and fixed.
Common mistakes
The first mistake is copying a pool size from a development framework default into production. A default of 10 connections per process may be harmless on one laptop process and dangerous when deployed across dozens of containers. The second mistake is letting background workers use the same database budget as user-facing traffic. Batch jobs are important, but they should not consume every slot during a retry storm.
Another common mistake is using max_connections as the target rather than the ceiling. A database set to allow 200 connections is not saying that 200 busy sessions are optimal. It is defining how many sessions may exist before the server refuses more. Throughput usually has a lower sweet spot, especially for CPU-bound workloads or workloads with lock contention.
Finally, teams sometimes add PgBouncer and leave application pools unchanged. That may solve server connection exhaustion but still creates a huge client-side queue. The better outcome is intentional pressure at every layer: small process pools, a PgBouncer server pool sized for database capacity, and PostgreSQL limits that leave room for maintenance.
Takeaway
Good PostgreSQL pool sizing is a concurrency control problem. Decide how much database work can run at once, reserve space for operations, separate web and worker budgets, and put queues where they are easiest to observe. PgBouncer is valuable because it lets many application clients share fewer PostgreSQL sessions, but it works best when application pools are still deliberate.
If you are running on managed PostgreSQL, use the platform to simplify the operational pieces: predictable connection endpoints, PgBouncer, backups, and clear plan limits. The sizing decision still belongs to the application architecture. Start conservative, measure wait time and active sessions, then expand only when the database has real headroom.
Sources and further reading
- PostgreSQL documentation on connections and authentication: https://www.postgresql.org/docs/current/runtime-config-connection.html
- PostgreSQL documentation on kernel resources: https://www.postgresql.org/docs/current/kernel-resources.html
- PostgreSQL documentation on statistics and monitoring: https://www.postgresql.org/docs/current/monitoring-stats.html
- PgBouncer configuration reference: https://www.pgbouncer.org/config.html
- PgBouncer feature matrix and pooling modes: https://www.pgbouncer.org/features.html
Topic
Deep Dives
Updated
Jun 22, 2026
Read time
9 min read
ArmorDB Engineering writes about PostgreSQL operations, security, and infrastructure decisions for teams building production apps on ArmorDB.
Read next
Short-Form & Quick Fixes · 5 min read
Fix PostgreSQL Statement Timeout Without Hiding Slow Queries
Learn why PostgreSQL raises statement timeout errors, how to set safer timeout scopes, and how to decide whether the real fix is query tuning, indexing, or request budgeting.
Read articleTech-News & Trends · 7 min read
PostgreSQL 18 Temporal Constraints: What WITHOUT OVERLAPS and PERIOD Change
PostgreSQL 18 adds temporal primary keys, unique constraints, and foreign keys. Here is what changed, when to use them, and what to test before production.
Read article