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. used equals max, and PostgreSQL shows most of those sessions idle for minutes. A connection opened by code and never disposed stays checked out of the pool even though no command is running on it. NpgsqlDataReader instances left open have the same effect.
  • Thread-pool starvation. used is well below max, yet requests time out. threadpool-queue-length climbs steadily. Synchronous calls — .Result, .Wait(), Open() instead of OpenAsync() 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 in db.client.connections.usage grouped 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. used equals max, sessions are active, and the queries are slow. Only here does pool size or query time need to change.
Decision tree for the Npgsql pool exhausted exception If used connections are below the maximum, check thread-pool queue length for starvation or distinct pool names for splitting. If used equals the maximum, idle server sessions indicate undisposed connections and active ones indicate genuine exhaustion. Pool exhausted exception after Timeout seconds used below Max Pool Size check thread pool and pool names used = Max Pool Size check pg_stat_activity state Thread-pool starvation queue length rising sync-over-async Pool splitting many connection strings one pool each Undisposed connections server shows idle missing using Genuine exhaustion server shows active slow queries
The same exception has four causes. Metrics from the process and from pg_stat_activity together identify which one is present.

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.

Connection hold time with and without disposal With await using, a connection is returned to the pool as soon as the query finishes, about 30 milliseconds. Without disposal, it stays checked out until the request object is garbage collected, which can take many seconds. Time a connection stays checked out for one request await using var conn returned after 30 ms, when the query completes conn = OpenConnection() still checked out: returned only if and when finalised 0 s 20 s 100 undisposed connections at 5 requests per second exhaust the default pool in 20 seconds
An undisposed connection is held until the garbage collector finalises its owner, not until the request ends. Holding time, not request rate, is what exhausts the pool.

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.

Pool splitting from rotating connection strings On the left, three token refreshes produce three connection strings and three separate pools of up to 76 connections each. On the right, one NpgsqlDataSource with a periodic password provider keeps a single pool across refreshes. Token in the connection string string with token A string with token B string with token C pool 1 · up to 76 pool 2 · up to 76 pool 3 · up to 76 up to 228 sessions from one replica Periodic password provider NpgsqlDataSource one connection string tokens refreshed every 10 min one pool up to 76 limit holds across every refresh
Pools are keyed on the full connection string. Putting a rotating token into the string creates a new pool on every rotation, and the old pools keep their connections until pruned.

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?
It changes the model: commands from many callers are written onto a small number of physical connections, so the pool no longer bounds concurrent commands one-to-one. It helps high-concurrency, short-query services, but it does not support every feature — notably, transactions still take a connection exclusively. Treat it as a throughput optimisation after the leaks are fixed, not as a leak workaround.
Why do I see more sessions than Maximum Pool Size?
Either several pools exist because connection strings differ, or several processes share the application name. Each 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?
No. It pools 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?
Only modestly. Every replica holds its minimum permanently, so the fleet total can consume a large share of 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?
Not by default; it relies on keepalives, 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.