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

aspnetcoreef-coreperformance
DbContext Pooling in EF Core: When It Helps and When It Bites

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. The switch is a one-word edit that also changes the lifecycle rules of your context. Code that was fine with AddDbContext can start leaking state across requests.

What Pooling Changes

The registration looks almost identical:

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

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. Fields you add to your own DbContext subclass are not reset.

The default pool size is 1024. Under burst load beyond that, extra contexts are created and 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 on by default; 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:

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 does its job. With AddDbContextPool, request B rents the context request A used. If B's middleware fails to set TenantId, B queries A's tenant data without any error. 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:

// 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 need this exact adjustment before they are pool-safe.

Bite 2: The Constructor Contract

A pooled context should expose a single public constructor that takes only DbContextOptions<T>. The pool constructs instances itself, outside any request scope, so it cannot supply request-scoped dependencies. Since EF Core 8 it resolves extra constructor parameters from the root service provider, which only suits singletons:

// Works with AddDbContext. With AddDbContextPool, a scoped ICurrentUser throws on first use
// when scope validation is on, and is shared by every request when it is off
public class AppDbContext(
    DbContextOptions<AppDbContext> options,
    ICurrentUser currentUser) : DbContext(options)
{
}

If your context injects the current user for audit logging, or a tenant service, pooling forces a redesign: move the dependency out of the constructor and into an interceptor 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:
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, while your own fields keep their values.

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, since the construction savings are negligible and the lifecycle rules still apply.

For more context lifetime guidance, see 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 cannot take request-scoped services, and singletons that hold a rental never return it to the pool.

The performance win is concentrated in hot endpoints where per-request query work is tiny. Leaked tenant or user state on a reused context is a correctness bug that only shows up once requests from more than one tenant or user hit the app.

Before you pool a stateful context, make sure that state is assigned unconditionally on every rental.

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. The pool creates contexts outside any request scope, so a pooled context should 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 disposed instead of returned, so the pool never blocks.

  • Pagination in ASP.NET Core With EF Core

    Loading all records at once makes your API slow and spikes server memory. Here is how to implement offset and keyset pagination in ASP.NET Core with EF Core.

  • Understanding the EF Core Change Tracker

    The EF Core change tracker records every entity you load or add, figures out what changed, and generates the right SQL on SaveChanges. Here is how entity states, identity resolution, and no-tracking queries work.

  • EF Core DbContext: Configuration and Best Practices

    DbContext configuration decides how your app behaves under load: service lifetime, tracking defaults, pooling, retries, and interceptors. This guide covers the settings that matter, the mistakes that cause concurrency bugs, and when context pooling is worth it.

Browse all EF Core guides →
Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.