Connection Pooling
Size the database connection pool against replica count and max_connections, and find the leaks and slow paths behind pool timeouts.
var csb = new NpgsqlConnectionStringBuilder(connectionString)
{ MaxPoolSize = 17, Timeout = 5, ConnectionIdleLifetime = 300, ApplicationName = "orders-api" };
var dataSource = NpgsqlDataSource.Create(csb.ConnectionString);Opening a PostgreSQL connection is expensive: TCP and usually TLS handshakes, SCRAM authentication, and a dedicated server process forked for the session that costs megabytes of memory. A pool keeps physical connections open and lends them out: OpenAsync() takes an idle one if available, and Dispose resets its session state and returns it, still connected.
The rules
- Pools are per process and per connection string; in Npgsql, per
NpgsqlDataSource. Two strings that differ only inApplication Nameare two pools with two budgets. - Npgsql defaults:
Maximum Pool Size=100,Minimum Pool Size=0, idle connections closed afterConnection Idle Lifetime=300seconds, and a caller waits up toTimeout=15seconds for a free connection before "The connection pool has been exhausted". SqlClient also defaults to 100 and a 15 second timeout. - A connection counts against the pool only while open. Open late, dispose early, never hold one across a call to another service.
Minimum Pool Sizeabove 0 keeps warm connections so the first requests after a quiet period skip the handshake, but every pod then holds them permanently.
Pool size x pods vs max_connections
PostgreSQL's max_connections defaults to 100, a few reserved for superusers. Every replica may open up to its own maximum, so six pods at the default 100 can ask for 600, and the first one over the limit gets FATAL: sorry, too many clients already (SQLSTATE 53300). Budget for the worst case: the autoscaler's maximum plus the rolling-update surge, plus migration jobs, cron jobs and admin tools.
per-pod max = (max_connections - reserved - headroom) / (max replicas + surge)
= (200 - 10 - 20) / (8 + 2) = 17
A bigger pool does not make the database faster. Connections in use are roughly throughput times query time (Little's law): 400 queries per second at 10 ms keeps about 4 busy. PostgreSQL throughput peaks at a number of active sessions not far above its CPU cores; past that, extra connections add contention. When the fleet needs more client connections than the server can run, put PgBouncer in transaction mode in front: many client connections share few server ones, at the cost of session features (session-level SET, advisory locks, LISTEN, and on older versions prepared statements).
Leaks and timeouts
A leak is a connection never returned: a missing await using, a reader left open, a transaction held while the code awaits an HTTP call. Nothing fails at the leak; minutes later requests wait 15 seconds and fail with pool exhaustion while the database sits idle. In pg_stat_activity, many idle in transaction sessions point at code holding transactions, many active ones at slow queries. Npgsql publishes pool metrics on the Npgsql meter for dotnet-counters and OpenTelemetry; set idle_in_transaction_session_timeout on the server as a backstop. Thread pool starvation from sync-over-async looks similar, because blocked threads return connections late.
After a failover, pooled connections to the old primary break; Npgsql discards a connection that fails, and Connection Lifetime rotates long-lived ones so load reaches new replicas. AddDbContextPool pools DbContext objects, not connections.
Example
using Dapper;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// 8080 serves the API through the Service; 8081 is internal-only (not in the Service or ingress).
builder.WebHost.UseUrls("http://+:8080", "http://+:8081");
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("operator", policy => policy.RequireAuthenticatedUser().RequireRole("db-operator"));
var csb = new NpgsqlConnectionStringBuilder(builder.Configuration.GetConnectionString("Shop")
?? throw new InvalidOperationException("ConnectionStrings:Shop is not configured."))
{
// (max_connections 200 - 10 reserved - 20 headroom) / (8 max replicas + 2 surge)
MaxPoolSize = builder.Configuration.GetValue("Database:MaxPoolSize", 17),
MinPoolSize = 0,
// Fail a request after 5 s of waiting for a connection instead of the default 15 s.
Timeout = 5,
CommandTimeout = 30,
ConnectionIdleLifetime = 300,
// Rotate connections hourly so traffic rebalances after a failover or a new replica.
ConnectionLifetime = 3600,
ApplicationName = "orders-api",
};
// One data source for the whole process: one pool, one budget.
builder.Services.AddSingleton(NpgsqlDataSource.Create(csb.ConnectionString));
var app = builder.Build();
app.MapGet("/orders/{id:long}/total", async (long id, NpgsqlDataSource db, CancellationToken ct) =>
{
// Rented for this statement only and back in the pool before the response is written.
await using var connection = await db.OpenConnectionAsync(ct);
var total = await connection.ExecuteScalarAsync<decimal?>(new CommandDefinition(
"SELECT total_amount FROM orders WHERE id = @id", new { id }, cancellationToken: ct));
return total is null ? Results.NotFound() : Results.Ok(new { id, total });
});
// Internal diagnostics: how this service's sessions look from the server side.
// Never public: it reveals database state and each call rents a pooled connection.
// The gate is the port the connection arrived on plus an operator policy, not the Host header,
// which the client sets and RequireHost would match.
app.MapGet("/internal/db/sessions", async (NpgsqlDataSource db, CancellationToken ct) =>
{
await using var connection = await db.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<SessionCount>(new CommandDefinition("""
SELECT coalesce(state, 'unknown') AS state, count(*)::int AS sessions
FROM pg_stat_activity
WHERE application_name =
GROUP BY state
ORDER BY sessions DESC
""", new { app = "orders-api" }, cancellationToken: ct));
return Results.Ok(rows);
})
.AddEndpointFilter(async (ctx, next) =>
ctx.HttpContext.Connection.LocalPort == 8081 ? await next(ctx) : Results.NotFound())
.RequireAuthorization("operator");
app.Run();
public sealed record SessionCount(string State, int Sessions);When to use it
- Cap Maximum Pool Size at 17 per pod so an orders API that autoscales to 8 replicas plus surge never exceeds a 200-connection PostgreSQL
- Diagnose checkout requests failing after 15 seconds with pool exhaustion by finding idle in transaction sessions held across payment calls
- Put PgBouncer in transaction mode in front of PostgreSQL when 40 worker pods each need a handful of connections
More examples
Pool settings in the connection string
The same knobs as the builder, as keywords. <code>Timeout</code> bounds both opening a new physical connection and waiting for a free pooled one. <code>Keepalive=30</code> makes Npgsql send a keepalive query after 30 seconds of inactivity (a protocol message, not an OS-level TCP keepalive, which is <code>Tcp Keepalive=true</code>) so a NAT or load balancer does not silently drop idle pooled connections, which otherwise shows up as a failed first query after a quiet period.
{
"ConnectionStrings": {
// Password comes from the secret store via ConnectionStrings__Shop in production.
"Shop": "Host=orders-db.internal;Port=5432;Database=shop;Username=orders_api;Password=<from-secret-store>;Maximum Pool Size=17;Minimum Pool Size=0;Timeout=5;Command Timeout=30;Connection Idle Lifetime=300;Connection Lifetime=3600;Application Name=orders-api;Keepalive=30"
},
"Database": {
"MaxPoolSize": 17
}
}Pitfall: holding a connection across an HTTP call
This is the most common leak that is not a missing <code>Dispose</code>: the connection is returned eventually, but it is held for the duration of a remote call. By Little's law, 50 requests per second times 2 seconds needs 100 connections. Npgsql sends <code>BEGIN</code> lazily with the first statement, so the <code>SELECT ... FOR UPDATE</code> is what makes these sessions show as <code>idle in transaction</code>; without any statement before the call they show as plain <code>idle</code> while still holding the pool slot. The fix holds a connection only for two short statements. The <code>Paying</code> claim stops a second request from starting another charge, and the gateway's idempotency key is what prevents a double charge when a retry or a reconciliation job for orders stuck in <code>Paying</code> calls again.
using Dapper;
using Npgsql;
public sealed record PaymentResult(string Reference, bool Approved);
public interface IPaymentGateway
{
// The gateway returns the original result, without charging again, for a repeated idempotency key.
Task<PaymentResult> ChargeAsync(long orderId, decimal amount, string idempotencyKey, CancellationToken ct);
}
public sealed class CheckoutService(NpgsqlDataSource dataSource, IPaymentGateway gateway)
{
// Broken: the connection, an open transaction and a row lock stay checked out for the
// whole gateway call. At 2 s per charge and 50 checkouts a second, the pool of 17 is
// gone and every other endpoint queues behind checkout.
public async Task PayBrokenAsync(long orderId, decimal amount, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var tx = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(
"SELECT id FROM orders WHERE id = @orderId FOR UPDATE", new { orderId }, tx, cancellationToken: ct));
var result = await gateway.ChargeAsync(orderId, amount, $"order-{orderId}", ct);
await connection.ExecuteAsync(new CommandDefinition(
"UPDATE orders SET status = 'Paid', payment_ref = @Reference WHERE id = @orderId",
new { result.Reference, orderId }, tx, cancellationToken: ct));
await tx.CommitAsync(ct);
}
// Fixed: claim the order in a short statement, call the gateway with no connection held,
// then record the result in another short statement.
public async Task PayAsync(long orderId, decimal amount, CancellationToken ct)
{
await using (var connection = await dataSource.OpenConnectionAsync(ct))
{
var claimed = await connection.ExecuteAsync(new CommandDefinition(
"UPDATE orders SET status = 'Paying' WHERE id = @orderId AND status = 'Pending'",
new { orderId }, cancellationToken: ct));
if (claimed == 0) return; // already paid or being paid by another request
}
// Same key on every attempt for this order, so a retry can never charge twice.
var result = await gateway.ChargeAsync(orderId, amount, $"order-{orderId}", ct);
await using (var connection = await dataSource.OpenConnectionAsync(ct))
{
await connection.ExecuteAsync(new CommandDefinition(
"UPDATE orders SET status = @status, payment_ref = @Reference WHERE id = @orderId AND status = 'Paying'",
new { status = result.Approved ? "Paid" : "Pending", result.Reference, orderId }, cancellationToken: ct));
}
}
}Reading the pool from the database side
Setting <code>Application Name</code> per service is what makes the first query useful: every pool shows up under its own name. The second query finds code holding transactions open, and its <code>last_query</code> usually names the culprit. <code>idle_in_transaction_session_timeout</code> limits the damage of a leak, but the session it kills was holding locks and its work is rolled back, so fix the code too.
-- Sessions per application and state: who is using the connection budget.
SELECT application_name, state, count(*) AS sessions
FROM pg_stat_activity
WHERE datname = 'shop'
GROUP BY application_name, state
ORDER BY sessions DESC;
-- Transactions left open by application code, oldest first.
SELECT pid, application_name, now() - xact_start AS open_for, left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
-- The limits being budgeted against.
SHOW max_connections;
SHOW superuser_reserved_connections;
-- Backstop: end sessions that sit idle inside a transaction for over a minute.
ALTER DATABASE shop SET idle_in_transaction_session_timeout = '60s';
Discussion