.NET & ADO.NET Connection Pooling
This guide is part of Pool Architecture & Algorithm Fundamentals. ADO.NET differs from every other stack covered on this site in one structural respect: the pool is not an object you construct and hold. It is managed by the provider, keyed on the exact connection string, and entirely invisible in application code — a SqlConnection is opened and disposed per operation, and the pool underneath decides whether that means a real handshake or a checkout from a warm set.
That design makes correct usage almost automatic and makes two specific mistakes almost invisible. Building the connection string dynamically silently creates a separate pool per variant, and forgetting to dispose a connection leaks a pooled entry rather than a socket, so the leak is only visible as Timeout expired errors much later. This guide covers the pooling model, the settings that govern it in SqlClient and Npgsql, and the diagnosis of both failure modes.
Key operational takeaways:
- The pool is keyed on the exact connection string — any difference, including whitespace or ordering, creates a second pool with its own
Max Pool Size. Max Pool Sizedefaults to 100 inSqlClient, which is far too large for a fleet sharing a database and is the usual cause oftoo many clientserrors.Timeout expired. The timeout period elapsed prior to obtaining a connection from the poolis a pool-exhaustion message, not a network one — the database is usually healthy.- Disposal is the release mechanism: a
SqlConnectionnot inside ausingblock holds its pooled entry until the finaliser runs, which may be a long time. Min Pool Sizeabove zero keeps connections warm and is worth setting when the database is in another availability zone.
Foundational Mechanics
ADO.NET’s pooling lives in the provider, below the DbConnection abstraction. When connection.Open() is called, the provider hashes the connection string, finds or creates the corresponding pool, and either returns a pooled connection or opens a new one if the pool is below Max Pool Size. When the connection is disposed, it returns to that pool rather than closing.
Three consequences follow, and they are the whole of the model.
Open and dispose per operation is correct. Unlike stacks where holding a connection object is normal, ADO.NET expects a short-lived SqlConnection inside a using block. Holding one across an operation boundary is what leaks, because the pooled entry is not returned until disposal.
The pool is process-wide and per-string. There is no way to have two differently-sized pools against the same connection string, and no way to have one pool across two different strings. The string is the identity.
Connection resets happen on reuse. SqlClient issues a reset (sp_reset_connection) when handing back a pooled connection, which clears temporary tables, session settings and transaction state. This is why session-state leakage is not the concern in ADO.NET that it is behind a transaction-mode proxy — the provider cleans up. It is also a small per-checkout cost that Connection Reset=false can remove at the price of that safety, which is almost never worth it.
Npgsql follows the same model with its own vocabulary: Maximum Pool Size, Minimum Pool Size, Connection Idle Lifetime, and Connection Pruning Interval. Its defaults are more conservative than SqlClient’s — a maximum of 100 in both, but Npgsql prunes idle connections aggressively where SqlClient keeps them longer.
| Concept | SqlClient | Npgsql | Default |
|---|---|---|---|
| Ceiling | Max Pool Size |
Maximum Pool Size |
100 — usually far too high |
| Floor | Min Pool Size |
Minimum Pool Size |
0 |
| Acquisition deadline | Connect Timeout |
Timeout |
15 s — usually too long |
| Idle reaping | implicit, ~4–8 min | Connection Idle Lifetime |
300 s (Npgsql) |
| Maximum age | not exposed | Connection Lifetime |
0 — disabled |
| Session cleanup | sp_reset_connection |
DISCARD ALL |
on by default |
The row worth changing first is the ceiling. A default of 100 per pool, multiplied by replicas and by any accidental pool duplication, produces connection counts that no shared database tolerates. Deriving it properly is covered in Connection Pool Sizing Formulas.
Operational Boundary: Provider-level pooling behaviour is in scope. Entity Framework’s DbContext lifetime — which determines how long a connection is held — is covered as an integration concern below.
Precision Sizing & Timeout Orchestration
The parameters interlock exactly as they do elsewhere, with two .NET-specific wrinkles.
Connect Timeout serves double duty: it bounds both the TCP/TLS handshake for a new connection and the wait for a pooled one. That conflation is unhelpful, because the two want different values — a handshake to a database in the same region should complete in well under a second, while a brief queue for a pooled connection might legitimately take two. The default of 15 seconds is too long for both purposes and is the reason .NET services under pool pressure hold request threads for so long before failing.
Min Pool Size matters more in .NET than in most stacks because there is no separate warm-up hook. Setting it above zero causes the provider to maintain that many connections, which removes the handshake from the first request after a quiet period. On a database in another availability zone, where a handshake with TLS can cost 40–80 ms, the difference is visible in p99.
# A service with a 240-connection allowance, 12 replicas at surge, 1 pool each.
Server=tcp:db.internal,1433;Database=orders;
Max Pool Size=20; # 240 ÷ 12 replicas
Min Pool Size=4; # trough concurrency — removes first-request handshakes
Connect Timeout=5; # below the 15 s request timeout; 15 s default is far too long
Application Name=orders-api; # attributable in sys.dm_exec_sessions / pg_stat_activity
Pooling=true;
The Application Name entry deserves its place. On both SQL Server and PostgreSQL it makes each service’s sessions attributable in server-side views, which converts “something is holding 400 connections” into a specific answer. It costs nothing — but note that it becomes part of the pool key, so it must be a constant per service rather than per tenant or per request.
Operational Boundary: Connection-string parameters and their interlocks are covered here. Provider-specific retry logic and SqlRetryLogicBaseProvider configuration are resilience concerns handled at the call site.
Production Configuration Examples
The configuration surface is the connection string plus, on modern .NET, a small amount of registration code. Both matter, and the registration is where the string is most often accidentally made variable.
ASP.NET Core with a constant string. The string is built once at start-up and never interpolated afterwards, which guarantees a single pool.
// Program.cs — one string, registered once.
var connectionString = builder.Configuration.GetConnectionString("Orders")
?? throw new InvalidOperationException("connection string not configured");
builder.Services.AddDbContext<OrdersContext>(options =>
options.UseSqlServer(connectionString, sql =>
{
sql.CommandTimeout(5); // statement-level bound
sql.EnableRetryOnFailure(
maxRetryCount: 2,
maxRetryDelay: TimeSpan.FromSeconds(2),
errorNumbersToAdd: null); // transient faults only
}));
Npgsql with an explicit data source. Modern Npgsql prefers NpgsqlDataSource, which owns the pool explicitly and makes it a singleton by construction — closer to the model used by every other stack on this site, and much harder to duplicate accidentally.
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = 15;
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = 3;
dataSourceBuilder.ConnectionStringBuilder.Timeout = 5; // acquisition + handshake
dataSourceBuilder.ConnectionStringBuilder.ConnectionIdleLifetime = 300;
dataSourceBuilder.ConnectionStringBuilder.ConnectionLifetime = 1500; // below the shortest reaper
dataSourceBuilder.ConnectionStringBuilder.MaxAutoPrepare = 0; // required behind a proxy
dataSourceBuilder.ConnectionStringBuilder.ApplicationName = "orders-api";
builder.Services.AddSingleton(dataSourceBuilder.Build()); // singleton, one pool
Multi-tenant, done correctly. Tenancy must not vary the connection string. Setting the tenant per transaction keeps one pool and keeps the state inside the assignment window:
await using var conn = await dataSource.OpenConnectionAsync();
await using var tx = await conn.BeginTransactionAsync();
await using (var cmd = new NpgsqlCommand("SET LOCAL search_path = @schema", conn, tx))
{
cmd.Parameters.AddWithValue("schema", tenant.Schema); // SET LOCAL — reverts at commit
await cmd.ExecuteNonQueryAsync();
}
// … tenant-scoped work …
await tx.CommitAsync();
The contrast with the earlier failure mode is exact: putting the tenant in the connection string produces one pool per tenant, while putting it in a SET LOCAL inside the transaction produces one pool and correct isolation. The second is also the only form that works behind a transaction-mode proxy.
Operational Boundary: Provider and EF Core configuration are covered here. Choosing between SqlClient and a third-party provider, or between EF Core and Dapper, is an application-architecture decision that does not change the pooling model.
Diagnostics & Telemetry
.NET exposes pool state through performance counters on the .NET Framework and, on modern .NET, through EventCounters under the Microsoft.Data.SqlClient.EventSource and Npgsql providers. Both surface the numbers that matter, but neither is enabled by default, which is why most .NET services have no pool visibility until an incident forces the question.
The counters worth exporting:
| Counter | Provider | What A Bad Value Means |
|---|---|---|
active-hard-connections |
SqlClient | Physical connections — should be stable, not climbing |
active-soft-connections |
SqlClient | Checked-out connections — the utilisation signal |
number-of-free-connections |
SqlClient | Zero for sustained periods means no headroom |
number-of-stasis-connections |
SqlClient | Connections awaiting completion — non-zero indicates leaked work |
Busy Connections |
Npgsql | Equivalent of checked-out |
Idle Connections |
Npgsql | Equivalent of free |
The stasis counter is genuinely .NET-specific and worth understanding. A connection enters stasis when it has been returned but has outstanding work — an unclosed SqlDataReader, most commonly — so it can neither be reused nor closed. A rising stasis count is the ADO.NET signature of the same bug that manifests as an unclosed cursor elsewhere.
The server side is the complementary view. On SQL Server, sys.dm_exec_sessions grouped by program_name shows the per-service session count, and sys.dm_exec_connections distinguishes them by client address. On PostgreSQL with Npgsql, pg_stat_activity grouped by application_name does the same. When the client-side counters are unavailable — which is common in a running incident — this is how the pool count is established at all.
Integration & Proxy Compatibility
Entity Framework Core sits above ADO.NET and changes when connections are opened rather than how they are pooled. A DbContext opens a connection lazily on first query and — critically — keeps it open for the lifetime of the context if a transaction is active, or releases it between operations if not. Registering DbContext with AddDbContext gives it a scoped lifetime tied to the request, which means a long-running request holds a connection for its duration whenever a transaction is open.
DbContextPool, configured with AddDbContextPool, pools the context objects rather than connections. It reduces allocation cost and is unrelated to connection pooling, which is a common source of confusion — enabling it does not change the connection count at all.
Behind a multiplexing proxy, .NET behaves well in one respect and badly in another. The sp_reset_connection / DISCARD ALL behaviour means session state is cleaned on reuse, so the state-leak class of failures is largely absent. But SqlClient uses named prepared statements when Enlist and statement caching are active, and Npgsql prepares automatically when Max Auto Prepare is non-zero — both of which break under transaction pooling exactly as described in Using Prepared Statements with PgBouncer Transaction Mode. Setting Max Auto Prepare=0 is the Npgsql equivalent of disabling server-side prepares.
Distributed transactions deserve a specific warning. TransactionScope promoting to a distributed transaction pins the connection for the scope’s duration and interacts poorly with both pooling and proxies. On modern .NET the promotion is less automatic than it once was, but a TransactionScope spanning two connections still triggers it, and the resulting hold times are long enough to change the sizing arithmetic materially.
Common Failure Patterns & Remediation
| Symptom | Root Cause | Exact Fix | Validation |
|---|---|---|---|
Timeout expired… prior to obtaining a connection |
Pool exhausted | Reduce hold time, or raise Max Pool Size within budget |
Server session count stays under the configured maximum |
Server sessions far exceed Max Pool Size |
Connection string built dynamically | Hoist the string to a constant; move variance out of it | Session count matches maximum × replicas |
| Connections accumulate, restart clears it | Missing using / Dispose on a connection or reader |
Wrap in using; audit error paths |
Stasis counter returns to zero |
stasis counter climbing |
SqlDataReader not closed before disposal |
Close readers explicitly, or use using on both |
Counter flat under sustained load |
| First request after quiet period is slow | Min Pool Size at 0 |
Set it to trough concurrency | Handshake latency absent from p99 |
prepared statement does not exist behind a proxy |
Npgsql auto-preparation under transaction pooling | Max Auto Prepare=0 |
Error absent under sustained proxy load |
| Long holds on a small number of requests | TransactionScope promoted to distributed |
Keep the scope to one connection | Hold-time p99 returns to the query-time range |
Frequently Asked Questions
Does Enlist=false change pooling behaviour?
TransactionScope, which prevents the accidental promotion to a distributed transaction that pins a connection for the scope’s duration. On a service that does not intentionally use distributed transactions it is a reasonable defensive setting, and it removes one of the harder-to-diagnose sources of long hold times.Does await using behave differently from using for connections?
await using additionally awaits DisposeAsync, which lets the provider complete any asynchronous cleanup without blocking a thread. On an async code path it is the correct form; the pooling consequence of getting it wrong is the same either way, which is that the connection is returned late or not at all.How does ClearPool fit into recovery?
SqlConnection.ClearPool discards every pooled connection for a given connection string, forcing fresh ones on the next open. It is occasionally useful after a failover has invalidated every connection at once, but it is a blunt instrument — the pool recovers on its own as invalid connections are detected, and clearing it during load produces a handshake storm.Should Pooling=false ever be set?
Does Max Pool Size apply per process or per application?
Why does the connection count sometimes exceed Max Pool Size on the server?
Application Name, a differently-ordered keyword list, or a password rotation producing two variants during the roll. Group server sessions by program_name and client address to confirm.Is Connection Lifetime in Npgsql the same as maxLifetime elsewhere?
How do I attribute connections when several .NET services share a database?
Application Name per service — constant, not per tenant. It appears in sys.dm_exec_sessions.program_name on SQL Server and pg_stat_activity.application_name on PostgreSQL, and it is the cheapest connection observability available in the stack.Related
- Pool Architecture & Algorithm Fundamentals — the parent reference on topology, algorithms and lifecycle.
- Tuning Max Pool Size in SqlClient Connection Strings — deriving the value and applying it safely.
- Fixing Timeout Expired Pool Exhaustion in Entity Framework Core — incident procedure for the signature .NET failure.
- Connection Pool Sizing Formulas — the arithmetic behind whatever value you set.
- Azure SQL Connection Management — the tier-imposed limits most .NET services meet first.