Diagnosing Npgsql Connection Pool Exhaustion
This guide is part of .NET & ADO.NET Connection Pooling. Npgsql, the PostgreSQL provider for .NET, keeps its own pool rather than using the SqlClient implementation, and it reports exhaustion with an unusually direct message:
Npgsql.NpgsqlException (0x80004005): The connection pool has been exhausted, either raise
'Max Pool Size' (currently 100) or 'Timeout' (currently 15 seconds) in your connection string.
---> System.TimeoutException: The operation has timed out.
at Npgsql.PoolingDataSource.g__RentAsync|34_0(...)
The message suggests two remedies, and both are usually wrong. Raising Max Pool Size pushes more sessions onto PostgreSQL; raising Timeout lengthens the stall. In most incidents the pool is large enough and connections are being held by code that forgot to dispose them, by threads blocked on synchronous calls, or by several pools that the application does not realise it has. The objective is to tell those causes apart and fix the one that applies.
Rapid incident diagnosis
Count connections from both ends. On PostgreSQL, group the service’s sessions by state. Set Application Name in the connection string so this query can find them:
SELECT state, count(*) AS sessions, max(now() - state_change) AS longest_in_state
FROM pg_stat_activity
WHERE application_name = 'orders-api'
GROUP BY state;
In the process, read Npgsql’s own metrics. Npgsql 8 publishes OpenTelemetry-compatible instruments on a meter named Npgsql, which dotnet-counters can read from a running process without a redeploy:
dotnet-counters monitor --process-id $(pgrep -f Orders.Api) --counters Npgsql,System.Runtime
The instruments that matter are db.client.connections.usage split by state (idle and used), db.client.connections.max, db.client.connections.pending_requests and db.client.connections.timeouts. In the System.Runtime counters, watch threadpool-queue-length and threadpool-thread-count.
Match the pattern.
- Undisposed connections.
usedequalsmax, and PostgreSQL shows most of those sessionsidlefor minutes. A connection opened by code and never disposed stays checked out of the pool even though no command is running on it.NpgsqlDataReaderinstances left open have the same effect. - Thread-pool starvation.
usedis well belowmax, yet requests time out.threadpool-queue-lengthclimbs steadily. Synchronous calls —.Result,.Wait(),Open()instead ofOpenAsync()in async code — block thread-pool threads, and the continuation that would return a connection cannot be scheduled. The pool error is a symptom; the thread pool is the bottleneck. - Pool splitting. PostgreSQL shows more sessions than
Max Pool Size, or several distinct counts indb.client.connections.usagegrouped by pool name. Every distinct connection string creates a separate pool. Building strings per request — adding a tenant’s search path, rotating a password token into the string — produces one pool per variant, each with its own limit. - Genuine exhaustion.
usedequalsmax, sessions areactive, and the queries are slow. Only here does pool size or query time need to change.
Mathematical sizing or parameter formula
Size the pool only after ruling out the first three causes, and size it from concurrency rather than request rate. For an ASP.NET Core service, the useful upper bound on simultaneous database work is:
Max Pool Size = ceil( peak_concurrent_requests × fraction_touching_db × db_time ÷ request_time ) + background_workers
A service peaking at 400 in-flight requests, where 70 % touch the database and spend 30 ms of a 120 ms request inside it, needs 400 × 0.7 × 30 ÷ 120 = 70 connections for requests, plus 6 for hosted background services: 76. The default of 100 is sufficient, which is itself diagnostic — if this service exhausts 100 connections, something is holding them longer than 30 ms.
Also check the fleet total against the server, because each replica has its own pool:
replicas × Max Pool Size ≤ max_connections − superuser_reserved_connections − other_clients
Twelve replicas at 100 need 1,200 slots; most PostgreSQL servers are configured for a few hundred. Lower Max Pool Size to the computed 76 and 12 replicas claim 912, which may still need PgBouncer in front.
The timeout should be short enough to shed load before the request deadline. With a 5-second upstream timeout, a Timeout of 3 seconds lets the service return a controlled error while the caller is still waiting.
Exact remediation & configuration
Own one data source. Since Npgsql 7, NpgsqlDataSource is the object that owns the pool. Register it once and inject it, rather than passing connection strings around:
// Program.cs — Npgsql.DependencyInjection
builder.Services.AddNpgsqlDataSource(
builder.Configuration.GetConnectionString("Orders")!,
dataSourceBuilder =>
{
dataSourceBuilder.EnableDynamicJson();
// Tokens and passwords rotate here, not by editing the connection string
dataSourceBuilder.UsePeriodicPasswordProvider(
async (settings, ct) => await tokenSource.GetTokenAsync(ct),
successRefreshInterval: TimeSpan.FromMinutes(10),
failureRefreshInterval: TimeSpan.FromSeconds(10));
});
builder.Services.AddDbContextPool<OrdersContext>((sp, options) =>
options.UseNpgsql(sp.GetRequiredService<NpgsqlDataSource>()));
UsePeriodicPasswordProvider is what removes the most common cause of pool splitting in cloud deployments: rotating IAM or Entra ID tokens without generating a new connection string, and therefore a new pool, every refresh.
Set explicit pool parameters.
{
"ConnectionStrings": {
"Orders": "Host=orders-db.internal;Database=orders;Username=orders_app;Application Name=orders-api;Maximum Pool Size=76;Minimum Pool Size=4;Timeout=3;Command Timeout=15;Connection Idle Lifetime=120;Connection Pruning Interval=10;Connection Lifetime=1800;Keepalive=30"
}
}
Connection Idle Lifetime and Connection Pruning Interval govern how quickly idle connections above Minimum Pool Size are closed. Connection Lifetime retires connections after a fixed age so that failovers and load-balancer changes take effect. Keepalive sends a protocol-level keepalive to stop NAT devices dropping idle sockets.
Dispose everything that rents a connection.
// Before: the connection is checked out until finalisation
public async Task<decimal> GetBalance(Guid id)
{
var conn = await _dataSource.OpenConnectionAsync();
var cmd = new NpgsqlCommand("SELECT balance FROM accounts WHERE id = $1", conn);
cmd.Parameters.AddWithValue(id);
return (decimal)(await cmd.ExecuteScalarAsync())!;
}
// After: returned to the pool the moment the method completes
public async Task<decimal> GetBalance(Guid id)
{
await using var cmd = _dataSource.CreateCommand("SELECT balance FROM accounts WHERE id = $1");
cmd.Parameters.AddWithValue(id);
return (decimal)(await cmd.ExecuteScalarAsync())!;
}
NpgsqlDataSource.CreateCommand opens and returns a connection around a single command, which removes the disposal problem for one-shot queries entirely.
Remove sync-over-async. Search for .Result, .Wait() and GetAwaiter().GetResult() on database calls, and replace Open() and ExecuteReader() with their async counterparts in request paths. The EF Core equivalents of these patterns are covered in Fixing Timeout Expired Pool Exhaustion in Entity Framework Core.
Behind PgBouncer in transaction mode, add No Reset On Close=true so Npgsql does not send DISCARD ALL on every return — PgBouncer resets server state itself — and keep automatic preparation (Max Auto Prepare) only if PgBouncer is 1.21 or later with max_prepared_statements enabled.
Validation & verification
Leak test in CI. An integration test that runs the endpoint many more times than the pool size catches undisposed connections before production:
[Fact]
public async Task Endpoint_does_not_leak_connections()
{
var client = _factory.CreateClient();
for (var i = 0; i < 500; i++) // far above Maximum Pool Size
(await client.GetAsync($"/accounts/{KnownId}/balance")).EnsureSuccessStatusCode();
await using var conn = await _admin.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand(
"SELECT count(*) FROM pg_stat_activity WHERE application_name = 'orders-api'", conn);
var sessions = (long)(await cmd.ExecuteScalarAsync())!;
Assert.True(sessions <= 76, $"expected at most 76 sessions, saw {sessions}");
}
With a leak, the loop fails on the pool timeout long before reaching 500.
Starvation test. Under load, threadpool-queue-length should stay near zero. A steady climb while db.client.connections.usage{state="used"} is below the maximum means a synchronous call is still blocking threads.
Splitting test. In production, count distinct pools in the metrics. There should be exactly one pool per data source the service is meant to have:
count by (service_name) (
count by (service_name, pool_name) (db_client_connections_max)
)
Server-side check. After the fix, the idle share of the service’s sessions in pg_stat_activity should track Minimum Pool Size during quiet periods, and no session should remain idle in transaction for longer than a request.
Continuous monitoring for Npgsql pools
Once the incident is fixed, export the pool instruments so the next leak or starvation shows up as a trend rather than an outage. Npgsql’s metrics use the System.Diagnostics.Metrics API, so the OpenTelemetry .NET SDK collects them with one line:
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("orders-api"))
.WithMetrics(m => m
.AddMeter("Npgsql")
.AddRuntimeInstrumentation()
.AddAspNetCoreInstrumentation()
.AddOtlpExporter())
.WithTracing(t => t
.AddSource("Npgsql")
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
Three alert conditions cover the causes on this page, each with a different owner:
| Condition | Expression (Prometheus names after OTLP export) | Likely cause |
|---|---|---|
| Pool saturated | db_client_connections_usage{state="used"} / db_client_connections_max > 0.9 for 5 m |
Genuine exhaustion or undisposed connections |
| Requests timing out on the pool | increase(db_client_connections_timeouts_total[5m]) > 0 |
Any cause; page-worthy |
| Thread pool starving | dotnet_thread_pool_queue_length > 50 for 5 m while pool usage < 0.5 |
Sync-over-async |
| Unexpected pool count | count by (service_name) (db_client_connections_max) > 1 |
Pool splitting |
The last row relies on each pool reporting its own series with a pool_name label. Give data sources explicit names through NpgsqlDataSourceBuilder.Name so the label is stable and readable:
var dsBuilder = new NpgsqlDataSourceBuilder(connectionString) { Name = "orders-primary" };
await using var dataSource = dsBuilder.Build();
Correlate the pool with the database weekly as well. Count pg_stat_activity sessions per application_name at peak and compare with replicas × Maximum Pool Size. The database count should be at or below the product; a count above it means a pool exists that the configuration does not describe, and a count far below it suggests Maximum Pool Size is larger than needed and could be reduced to free budget for other services.
Finally, include one pool-focused test in load testing before major releases: drive the service at its expected peak with Maximum Pool Size temporarily reduced by a third. The service should degrade by returning controlled errors quickly — Timeout of a few seconds, surfaced as 503 — rather than by hanging requests, which verifies that timeouts and error handling behave as designed while there is still time to change them.
Frequently Asked Questions
Is Npgsql’s Multiplexing=true a fix for exhaustion?
Why do I see more sessions than Maximum Pool Size?
NpgsqlDataSource and each distinct connection string has its own limit. Compare pool_name values in the metrics with the connection strings in configuration.Does AddDbContextPool pool database connections?
DbContext instances to avoid their construction cost. Connections are still rented from Npgsql’s pool for each operation and returned afterwards. The two pools are independent, and exhausting one does not exhaust the other.Should Minimum Pool Size be raised to avoid cold connections?
max_connections while idle. A small minimum plus a short Timeout is the better trade for most services.Does Npgsql validate connections when they are rented?
Connection Lifetime and error handling to discard broken connections. A connection that fails during a command is removed from the pool, and the next rent opens a new one. For networks that drop idle flows silently, set Keepalive so failures are detected before a request uses the connection.Related
- .NET & ADO.NET Connection Pooling — the parent guide on connection-string-keyed pools.
- Fixing Timeout Expired Pool Exhaustion in Entity Framework Core — the SqlClient and EF Core version of this incident.
- Tuning Max Pool Size in SqlClient Connection Strings — sizing the SQL Server provider’s pool.
- Detecting Connection Pool Saturation — the provider-independent version of the diagnosis.