# DbContext Pooling in EF Core: When It Helps and When It Bites

> AddDbContextPool can shave allocations off every request by recycling DbContext instances instead of creating them. But a pooled context is a reused object, and any state you stash on it silently leaks into the next request. Here is when pooling pays off and how to avoid its traps.

Published: 2026-08-20. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling

DbContext pooling, enabled with `AddDbContextPool`, keeps a pool of `DbContext` instances and rents one per scope instead of constructing a new one, then resets it and returns it at scope end.
It helps on high-RPS endpoints where the work per context is tiny, and it is a rounding error where the database round trip dominates.
It bites when your context carries custom state, which the pool does not reset.

Every request in a typical ASP.NET Core app constructs a `DbContext`, uses it for a handful of queries, and throws it away.
Construction is not free: EF Core sets up the change tracker, the service scope, and per-instance state, and disposal tears it down.

`AddDbContextPool` skips that churn by recycling instances.
On paper it is a one-word change.
In practice it changes the lifecycle rules of your context, and code that was fine with `AddDbContext` can start leaking state across requests.

Here is what pooling actually buys you, and the three ways it bites.

## What Pooling Changes

The registration looks almost identical:

```csharp
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Database")));
```

With plain `AddDbContext`, each scope constructs a new context and disposes it at scope end.
With pooling, "dispose" becomes "reset and return to pool", and "construct" becomes "rent from pool if one is available".

![Pooling lifecycle: a request rents a DbContext from the pool, uses it for queries, then on scope end EF state is reset and the change tracker cleared before the context returns to the pool](https://milanjovanovic.tech/blogs/articles/ef-core-dbcontext-pooling/pool-lifecycle.png)

The reset clears EF Core's internal state: the change tracker is emptied, connection state is handled, and the context is as good as new **from EF Core's perspective**.
That last qualifier is where the problems live.

The default pool size is 1024.
Under burst load beyond that, extra contexts are created and simply disposed instead of pooled, so the pool never becomes a bottleneck or a queue.
You can tune it with the second argument to `AddDbContextPool`, but change the default only after measuring pool saturation and allocation pressure.

Do not confuse context pooling with **connection** pooling.
ADO.NET connection pooling happens a layer below and is always on; a non-pooled context still reuses pooled database connections.
For the Npgsql side of that story, see **NpgsqlDataSource and connection pooling**.

## Is DbContext Pooling Worth It?

Pooling removes per-request allocation and setup of the context graph.
The EF team's own benchmarks show it matters most when the work per context is tiny: high-RPS endpoints running one cheap indexed query.
In those scenarios the requests-per-second improvement is material, but the result depends on how much work surrounds each context.

In a typical business app, the database round trip costs a few milliseconds and dominates the microseconds saved on construction.
There, pooling is a rounding error.

My rule: pooling is a legitimate optimization for hot, simple endpoints, and harmless elsewhere **if** your context is stateless.
Measure with BenchmarkDotNet or a load test before crediting it with anything.

## Bite 1: State on the Context Leaks Across Requests

This is the big one.
The pool resets what EF Core knows about, and nothing else.
Any field you added to your `DbContext` subclass survives into the next request:

```csharp
public class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options)
{
    // DANGER with pooling: this survives the reset
    public Guid TenantId { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasQueryFilter(o => o.TenantId == TenantId);
    }
}
```

With `AddDbContext`, this pattern works: each request gets a fresh context, middleware sets `TenantId`, the [**global query filter**](https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core) does its job.
With `AddDbContextPool`, request B rents the context request A used.
If B's middleware fails to set `TenantId`, B silently queries A's tenant data.
That is not a performance bug, that is a data breach.

If you pool a stateful context, the state must be set **unconditionally** on every rental.
And because a pooled context cannot inject scoped services (the next bite), the assignment has to happen after the rental.
The safe pattern is middleware that resolves the scoped context, which rents it from the pool, and assigns the value before any handler runs a query:

```csharp
// Middleware, runs on every request without exception
app.Use(async (httpContext, next) =>
{
    var db = httpContext.RequestServices
        .GetRequiredService<AppDbContext>();

    // Always assign, even when the request has no tenant
    db.TenantId = ResolveTenant(httpContext);

    await next();
});
```

Multi-tenancy is the most common place this trap appears, and the tenant-per-request patterns from [**multi-tenant applications with EF Core**](https://milanjovanovic.tech/blog/multi-tenant-applications-with-ef-core) need this exact adjustment before they are pool-safe.

## Bite 2: The Constructor Contract

A pooled context must expose a single public constructor that takes only `DbContextOptions<T>`.
The pool constructs instances itself, outside any request scope, so it cannot satisfy other dependencies:

```csharp
// Works with AddDbContext, throws at startup with AddDbContextPool
public class AppDbContext(
    DbContextOptions<AppDbContext> options,
    ICurrentUser currentUser) : DbContext(options)
{
}
```

If your context injects the current user for [**audit logging**](https://milanjovanovic.tech/blog/audit-logging-ef-core), or a tenant service, pooling forces a redesign: move the dependency out of the constructor and into an [**interceptor**](https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors) resolved from DI, or set it post-rental as shown above.
Interceptors registered via `options.AddInterceptors` in the pooled options are shared singletons, so they must be stateless too.

## Bite 3: Long-Lived Rentals Poison the Pool

The pool assumes short rentals.
A context held for the length of a background job, or one that tracked ten thousand entities, gets its change tracker cleared on return, but the internal structures may have grown, and while it is held, it is not available.

Two related mistakes:

- Injecting a pooled context into a singleton hosted service. The context is rented once and never returned. Use `IDbContextFactory<T>` instead, and there is a pooled variant:

```csharp
builder.Services.AddPooledDbContextFactory<AppDbContext>(options =>
    options.UseNpgsql(connectionString));

public class OrderSyncJob(IDbContextFactory<AppDbContext> factory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await using var context = await factory.CreateDbContextAsync(ct);
            // short-lived unit of work, context returns to pool on dispose
        }
    }
}
```

- Treating the pooled context as a cache because "it sticks around". The change tracker is wiped on every return. Anything you hoped would persist will not, and anything you did not expect to persist (your own fields) will. It is exactly backwards from what intuition suggests.

## My Recommendation

- Context has no custom state and no request-varying dependencies: pooling is low risk, but still measure it on the target workload.
- Context carries per-request state (tenant, user, soft-delete toggles): pool only after making the assignment unconditional on every rental, and add a test that hammers two tenants concurrently and asserts isolation.
- Background services: `AddPooledDbContextFactory`, one context per unit of work.
- Low-traffic apps: skip it. The complexity is real and the win is not.

More context lifetime guidance lives in [**DbContext configuration best practices**](https://milanjovanovic.tech/blog/dbcontext-configuration-best-practices).

## Summary

`AddDbContextPool` recycles context instances, trading construction cost for stricter lifecycle rules.
The pool resets EF Core's state, not yours: custom fields survive across requests, constructors are limited to options-only, and singletons that hold a rental starve the pool.

The performance win is real but narrow, concentrated in hot endpoints where per-request query work is tiny.
The failure mode is not narrow at all: leaked tenant or user state on a reused context is a correctness bug that only shows up under concurrent load.

Pool stateless contexts freely.
Pool stateful ones only after making the state impossible to forget.

## Frequently asked questions

### What does AddDbContextPool do in EF Core?

It maintains a pool of DbContext instances. When a request needs a context it rents one from the pool instead of constructing it, and when the scope ends the context is reset and returned to the pool instead of being disposed.

### Is DbContext pooling worth it?

It removes context construction and disposal overhead, which is measurable on hot paths with high request rates and cheap queries. For typical apps where database I/O dominates, the gain is small. Benchmark your own workload before deciding.

### Why is my data leaking between requests with DbContext pooling?

The pool resets EF Core internal state like the change tracker, but it cannot reset fields you added to your DbContext subclass. Any custom field, like a tenant id, keeps its value from the previous request unless you reset it explicitly.

### Can I use constructor injection of scoped services with a pooled DbContext?

No. A pooled context must have a single constructor accepting only DbContextOptions. To flow per-request state like the current tenant or user into a pooled context, set it after renting the context, typically from middleware that assigns it on every request.

### What is the default pool size for DbContext pooling?

The default maximum pool size is 1024 contexts. If all are in use, additional contexts are created normally and simply disposed instead of returned, so the pool never blocks.
