Npgsql Connection Pooling in ASP.NET Core: Prevent PostgreSQL Exhaustion
When an ASP.NET Core service slows down under load, PostgreSQL is often blamed before the application’s connection lifecycle is inspected. In reality, a pool is a deliberately bounded queue of scarce database sessions. Treating it as invisible infrastructure creates timeouts, queueing, and an avoidable outage when replicas scale.
This guide explains what Npgsql pools, how to register the data source correctly, and how to choose limits that your database can actually sustain. It assumes an ASP.NET Core API using PostgreSQL directly or through a library that uses Npgsql.
What Npgsql actually pools
Npgsql pools physical PostgreSQL connections by default. Disposing an NpgsqlConnection normally returns it to that pool; it does not tear down the underlying server session. That makes short-lived, per-operation connections both safe and fast—provided every path disposes them.
A pool belongs to the data source / connection-string configuration in a process. If one service has six replicas and each process can use 40 connections, PostgreSQL may see up to 240 application connections before accounting for jobs, migrations, dashboards, or reserved administrative capacity. Pool sizing is therefore a fleet decision, not an individual endpoint setting.
Register one data source and open connections late
Create the data source once at startup and inject it. Open a connection immediately before a database operation and dispose it immediately afterwards. Do not put an open connection in a singleton, cache it on a service, or carry it across unrelated requests.
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Connection string 'AppDb' is missing.");
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
var dataSource = dataSourceBuilder.Build();
builder.Services.AddSingleton(dataSource);
var app = builder.Build();
app.MapGet("/orders/{id:guid}", async (
Guid id,
NpgsqlDataSource dataSource,
CancellationToken cancellationToken) =>
{
const string sql = "SELECT id, total FROM orders WHERE id = $1";
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue(id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return Results.NotFound();
}
return Results.Ok(new
{
Id = reader.GetGuid(0),
Total = reader.GetDecimal(1)
});
});
app.Run();
The await using statements are not decoration. An undisposed reader, command, or connection can hold a pooled session until the process ends. Under concurrent traffic, that turns into pool waits even if PostgreSQL itself is healthy.
Set limits from database capacity, not guesswork
Start by reserving connections for PostgreSQL administration, migrations, background workers, and other applications. Divide the remaining budget across the maximum number of concurrently running API replicas. Then leave a safety margin for rollout overlap, because old and new replicas may briefly coexist.
{
"ConnectionStrings": {
"AppDb": "Host=db.internal;Database=orders;Username=app;Password=use-a-secret-store;Maximum Pool Size=40;Minimum Pool Size=5;Timeout=15;Command Timeout=30"
}
}
The values above are an example, not a universal recommendation. A large pool cannot create database CPU, I/O, locks, or throughput. It can merely allow more work to wait or contend. Measure request concurrency, query duration, and PostgreSQL’s active sessions before changing limits.
Keep pool waits distinct from slow SQL
A request may be slow because it waited for a pool slot, waited on a database lock, transferred too much data, or executed an inefficient query. These are different failures. Add request tracing and record command duration; then correlate pool-pressure symptoms with PostgreSQL activity. The operational patterns in our ASP.NET Core request-timeouts guide help ensure callers do not wait forever while you investigate.
Use cancellation, bounded queries, and safe defaults
Pass the request cancellation token to async Npgsql calls. It lets abandoned requests stop consuming application work where the driver and server can honor cancellation. Use parameterized SQL, request only the columns you need, paginate deliberately, and keep transactions short.
Npgsql documents that pooled connections are reset when returned so state does not leak between borrowers. Its No Reset On Close option trades that safeguard for a small, workload-specific optimization; do not enable it because it sounds faster. Benchmark it, understand the state your application sets, and prefer correctness by default.
Production checklist
- Register one
NpgsqlDataSourceper intended configuration and reuse it. - Dispose readers, commands, and connections on every success, cancellation, and failure path.
- Size each replica’s maximum pool against a shared database connection budget.
- Use parameterized commands and cancellation tokens.
- Monitor pool-wait symptoms, database sessions, query time, lock waits, and request latency together.
- Validate database reachability with a dependency-aware readiness check; see the ASP.NET Core health-checks guide.
Where this fits in an ASP.NET Core system
Connection pooling becomes easier to reason about when every component has a clear lifetime and configuration boundary. The .NET & ASP.NET Core learning track walks through those foundations, including dependency injection and EF Core data access.
Conclusion
Pool connections so PostgreSQL sessions are reused, but never treat the pool as unlimited. Keep usage short, dispose reliably, budget across replicas, and diagnose pool waits separately from query performance. Those habits turn a fragile database boundary into a controllable one.
1 Comments