.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 Size defaults to 100 in SqlClient, which is far too large for a fleet sharing a database and is the usual cause of too many clients errors.
  • Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool is a pool-exhaustion message, not a network one — the database is usually healthy.
  • Disposal is the release mechanism: a SqlConnection not inside a using block holds its pooled entry until the finaliser runs, which may be a long time.
  • Min Pool Size above zero keeps connections warm and is worth setting when the database is in another availability zone.
Pools are keyed by connection string Identical connection strings share one pool. Any textual difference, including a per-tenant application name or a reordered keyword, produces an independent pool with its own maximum size. one constant string Server=db;Database=orders;Max Pool Size=20 one pool, max 20 20 backends what the configuration says is what happens string built per tenant …;Application Name=tenant-a …;Application Name=tenant-b … × 40 tenants 40 independent pools, each with max 20 up to 800 backends from a configuration that reads "Max Pool Size=20" Nothing in application code reveals this — there is no pool object to inspect, and every individual pool is correctly sized.
Because the pool key is the connection string itself, per-tenant or per-request string construction multiplies pools invisibly while every individual pool remains correctly configured.

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.

Diagnosing a Timeout expired error Comparing the number of pools, the checked-out count, and the server-side session count separates accidental pool duplication from genuine exhaustion from a leak. Timeout expired obtaining a connection the pool, not the network server sessions vs Max Pool Size group sys.dm_exec_sessions by program_name far more accidental pool duplication the string is being built dynamically → hoist it to a constant matches genuine exhaustion or a leak check stasis count and session state → sleeping sessions mean a missing dispose the distinguishing query count sessions by program_name and compare to your configured maximum
One server-side query separates the two .NET-specific causes. If sessions greatly exceed the configured maximum, the connection string is being constructed dynamically.

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.

DbContext lifetime and connection hold time Without an open transaction EF Core opens and closes a connection per operation. With a transaction the connection is held for the whole scope, including any non-database work inside it. no explicit transaction — connection per operation query 1 query 2 SaveChanges connection held only during the green segments explicit transaction or TransactionScope connection held for the entire scope, including everything between the queries AddDbContextPool pools contexts, not connections It reduces allocation cost and changes the connection count not at all — a frequent and expensive misunderstanding
EF Core's connection hold time is decided by transaction scope, not by context lifetime. Context pooling is an allocation optimisation and has no effect on the number of connections.

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?
It stops the connection automatically enlisting in an ambient 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?
For pooling purposes, no — both dispose the connection and return it to the pool. 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?
Only for short-lived tooling — a migration runner or a one-shot CLI — where the process opens a handful of connections and exits. In a service it removes the entire benefit of ADO.NET’s model and adds a handshake to every operation.
Does Max Pool Size apply per process or per application?
Per process, per connection string. Two instances of the same service each get their own pool of that size, exactly as with any other stack, so the budget arithmetic uses the same replica multiplication as everywhere else.
Why does the connection count sometimes exceed Max Pool Size on the server?
Because there is more than one pool. The most common cause is a string that varies — a per-tenant 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?
Yes in intent — it caps how long a physical connection is reused — but it defaults to 0, meaning disabled. Behind a load balancer or NAT gateway with an idle reaper, setting it below that reaper is as necessary in Npgsql as anywhere else.
How do I attribute connections when several .NET services share a database?
Set a distinct 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.