A PostgreSQL connection pool is supposed to prevent an application from opening a new database connection for every request. Instead of creating and closing connections repeatedly, the application keeps a controlled number of connections available and reuses them.
When the pool starts running out, the first symptom is often an application timeout. API requests become slow, background jobs stop making progress, or new requests fail because they cannot obtain a database connection.
The easy response is to increase the maximum pool size.
That can work in some situations, but it can also make the database less stable. If connections are being held for too long, increasing the pool only allows more requests to wait or send more concurrent work to PostgreSQL.
The useful question is not just "How many connections should we allow?"
It is "Why are the existing connections staying busy for so long?"
What Is a PostgreSQL Connection Pool?
A connection pool maintains a set of reusable database connections.
Without pooling, an application might do this for every request:
Request
|
v
Open PostgreSQL connection
|
v
Execute query
|
v
Close connection
Opening a database connection repeatedly adds overhead and can create unnecessary load.
With a connection pool, the application follows a different pattern:
Application
|
v
Connection Pool
| | |
v v v
Conn Conn Conn
| | |
+-----+-----+
|
v
PostgreSQL
When an application needs a connection, it borrows one from the pool.
After the database operation finishes, the connection is returned to the pool.
The pool therefore controls concurrency between the application and PostgreSQL.
What Does "Pool Exhausted" Mean?
Suppose an application has a maximum pool size of 100.
If 100 connections are already in use, the next request cannot immediately obtain another connection.
It has to wait for an existing connection to become available.
Conceptually:
Maximum connections = 100
Connection 1 -> Busy
Connection 2 -> Busy
Connection 3 -> Busy
...
Connection 100 -> Busy
New request
|
v
Wait for available connection
If those connections become available quickly, the application may recover without a visible problem.
If they remain occupied for several seconds, request latency increases.
If the wait exceeds the configured timeout, the application can fail with a connection timeout.
This is why connection-pool exhaustion is often an application symptom rather than the original problem.
The First Thing to Check: Connection Wait Time
Before increasing the pool size, determine whether requests are actually waiting for connections.
If the application exposes connection-pool metrics, monitor values such as:
A useful pattern looks like this:
Requests increase
|
v
Connections become busy
|
v
Pool reaches maximum
|
v
Requests start waiting
|
v
Connection timeout
If this pattern occurs during traffic spikes, concurrency may be exceeding the pool's available capacity.
But if it happens under normal traffic, something may be holding connections longer than expected.
Check How Long Connections Stay Busy
One of the most important questions is:
"How long does a request hold a database connection?"
A normal request might look like:
Acquire connection
|
v
Run query
|
v
Read results
|
v
Return connection
A problematic request might look like:
Acquire connection
|
v
Run query
|
v
Call another service
|
v
Wait for HTTP response
|
v
Process business logic
|
v
Run another query
|
v
Return connection
The second pattern keeps the database connection occupied while the application is doing work that does not require the database.
That reduces effective pool capacity.
Do Not Hold a Database Connection While Calling External Services
This is one of the most common causes of unnecessarily long connection usage.
Consider:
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
var customer = await LoadCustomerAsync(connection);
var response = await httpClient.GetAsync(paymentServiceUrl);
await SaveResultAsync(connection, response);
The connection remains open while the application waits for the external HTTP request.
If the external service takes five seconds and the application has many concurrent requests, the connection pool can become exhausted even though the database queries themselves are fast.
A better design is to keep the connection scope around the actual database operations:
Customer customer;
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync();
customer = await LoadCustomerAsync(connection);
}
var response = await httpClient.GetAsync(paymentServiceUrl);
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync();
await SaveResultAsync(connection, response);
}
The exact implementation depends on the application's architecture, but the principle is important.
Only hold the database connection for as long as the database work requires it.
Transactions Can Keep Connections Busy
Transactions need particular attention because a transaction normally requires a database connection for its lifetime.
Consider:
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await UpdateOrderAsync(connection, transaction);
await ProcessBusinessLogicAsync();
await transaction.CommitAsync();
If ProcessBusinessLogicAsync takes a long time, the connection and transaction remain open during that period.
This can reduce pool availability.
A better approach is to keep transactions narrow.
Begin transaction
|
v
Required database operations
|
v
Commit transaction
|
v
Release connection
|
v
Do other work
The transaction should contain the database operations that must be atomic, not unrelated application processing.
Slow Queries Can Exhaust the Pool
Sometimes the application code is correctly returning connections, but the queries themselves are too slow.
For example:
Request 1 -> Query takes 5 seconds
Request 2 -> Query takes 5 seconds
Request 3 -> Query takes 5 seconds
...
Request 100 -> Query takes 5 seconds
All 100 connections can remain busy.
A larger pool may temporarily increase throughput, but it does not make the query faster.
This is why connection-pool investigation should be connected with database performance investigation.
Check:
If the database query is the bottleneck, fix the query before increasing connection concurrency.
Check for Connection Leaks
A connection leak occurs when the application acquires a connection but fails to return it to the pool properly.
In application code, connections should normally be disposed using an appropriate lifetime-management pattern.
For example:
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"SELECT customer_id FROM customers WHERE email = @email",
connection);
command.Parameters.AddWithValue("email", email);
await using var reader = await command.ExecuteReaderAsync();
The using scope ensures that the connection and related resources are disposed when the operation finishes.
The exact code depends on the data-access library, but the principle is the same: ownership and disposal of the connection should be clear.
Connection leaks are especially dangerous because the application may appear healthy until enough requests accumulate to consume the entire pool.
Async Code Does Not Automatically Prevent Pool Problems
Using async database APIs is important for scalable server applications, but async code does not automatically solve connection-pool exhaustion.
For example:
await connection.OpenAsync();
await command.ExecuteReaderAsync();
The thread is not unnecessarily blocked while the I/O operation waits.
However, the connection itself is still occupied.
That means this:
Async operation
|
v
Thread is available
|
v
Database connection remains busy
Async programming improves thread utilization.
It does not increase the number of database connections available in the pool.
This distinction is important when diagnosing high-concurrency applications.
Check PostgreSQL for Long-Running Sessions
PostgreSQL provides useful information through system views.
For example:
SELECT
pid,
usename,
state,
wait_event_type,
wait_event,
query_start,
now() - query_start AS duration,
query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start;
This can help identify sessions that have been active for a long time.
Pay attention to sessions that are:
running expensive queries
waiting on locks
sitting inside long transactions
unexpectedly idle in a transaction
The database view shows what PostgreSQL sees. You should compare that information with application metrics to determine why those connections are being held.
"Idle in Transaction" Needs Attention
An application can begin a transaction and then remain inactive while keeping the transaction open.
For example:
BEGIN
|
v
UPDATE
|
v
Application waits
|
v
User or external service processing
|
v
COMMIT
The database session may appear idle, but the transaction is still open.
Long-lived transactions can cause more than connection-pool problems. They can also interfere with vacuum cleanup and increase storage pressure depending on the workload.
This is why an idle-in-transaction session should not simply be dismissed as an inactive connection.
Check Lock Contention
A connection can also be busy because it is waiting for another transaction.
For example:
Transaction A
|
v
Locks row
|
v
Transaction B
|
v
Waits for lock
|
v
Connection remains occupied
If many application requests encounter the same lock, the connection pool can fill even though CPU usage may not look particularly high.
This is why database connection exhaustion should be investigated alongside locking behavior.
A slow application does not always mean the database is using too much CPU.
Sometimes connections are simply waiting.
Pool Size Is a Concurrency Setting
A connection pool's maximum size should not be treated as a number that can safely be increased without limit.
Suppose:
Application instances = 10
Pool size per instance = 100
The application could potentially create up to:
10 × 100 = 1,000 connections
That is very different from a single application instance with a pool of 100 connections.
If the PostgreSQL server cannot efficiently handle that concurrency, increasing the pool on every application instance can make the situation worse.
Always consider the total number of application instances.
Total possible connections
=
Application instances × Maximum pool size
This is one of the most important calculations when tuning connection pools in containerized or horizontally scaled applications.
Connection Pooling and PostgreSQL Limits Are Different
The application's pool controls how many connections that application can use.
PostgreSQL has its own connection limits.
For example:
Application pool
|
v
Maximum application connections
|
v
PostgreSQL max_connections
If several applications share the same PostgreSQL server, each application consumes part of the available database connection capacity.
Increasing one application's pool therefore affects the shared database.
Connection planning should consider:
The database server should not be treated as if the entire connection limit belongs to one application.
Connection Lifetime and Stale Connections
Connection pools reuse connections for efficiency, but long-lived connections can sometimes encounter network infrastructure issues or database-side changes.
Depending on the environment, connection lifetime and idle behavior may need to be configured appropriately.
For example, cloud load balancers, network firewalls, or database infrastructure may terminate connections that remain idle for too long.
A pool may then attempt to reuse a connection that is no longer usable.
This is an environment-specific issue, so investigate actual connection errors before changing lifetime settings.
A Practical Troubleshooting Process
When the application reports connection-pool exhaustion, use a consistent investigation process.
Step 1: Check Application Pool Metrics
Determine:
Step 2: Check PostgreSQL Activity
Inspect pg_stat_activity for long-running sessions, waits, and idle transactions.
Step 3: Find Slow Queries
Use application logs, database monitoring, and EXPLAIN ANALYZE where appropriate.
Step 4: Check Transaction Scope
Look for transactions that remain open while the application performs unrelated work.
Step 5: Check for Connection Leaks
Verify that every acquired connection is returned to the pool.
Step 6: Check Locking
Investigate sessions waiting on locks.
Step 7: Calculate Total Connection Capacity
For multiple application instances, calculate the possible number of simultaneous connections.
Step 8: Tune Only After Finding the Bottleneck
Increase pool size only when the database and application can safely support the additional concurrency.
Common Mistakes
Increasing the Pool Size Immediately
A larger pool can hide the problem temporarily while increasing database pressure.
Holding Connections During HTTP Calls
External service latency should not consume database connections unnecessarily.
Keeping Transactions Open Too Long
Transactions should cover the minimum required database work.
Ignoring Idle Transactions
An idle database session can still hold an open transaction and affect database behavior.
Looking Only at PostgreSQL
The root cause may be application code holding connections longer than necessary.
Ignoring Horizontal Scaling
A pool size that looks reasonable for one application instance can become excessive when multiplied across many instances.
Best Practices
Keep database connection scopes as short as practical.
Do not hold connections while waiting for external services.
Keep transactions narrow and focused.
Dispose connections reliably.
Monitor pool wait time and connection usage.
Monitor PostgreSQL sessions and long-running transactions.
Investigate slow queries and lock contention.
Calculate total connection capacity across all application instances.
Increase pool size only after confirming the database can handle it.
Treat connection-pool exhaustion as a symptom and investigate the underlying cause.
Conclusion
Connection-pool exhaustion usually means that database connections are being occupied faster or longer than the application can release them.
The cause may be slow queries, long transactions, connection leaks, lock contention, external calls inside database scopes, or simply too much concurrency for the configured pool.
Instead of immediately increasing the pool size, measure how connections are being used, inspect PostgreSQL activity, and identify what is keeping connections busy.
Join the conversation! Your thoughts help the community grow.