Skip to main content
SEMastery
Data Accessbeginner

How to Manage EF Core DbContext Lifetime: A Beginner's Guide

Learn how to manage EF Core DbContext lifetime safely. Understand scoped, transient, singleton, pooling, and DbContextFactory with simple examples and diagrams.

12 min readUpdated October 1, 2025

A shared notebook in the school office

Imagine the school office has one shared notebook. When a parent comes to update their child's address, a clerk opens the notebook, writes the change, saves it, and closes the notebook. The next parent gets a fresh, clean page for their own work.

Now imagine the office never closed that notebook. Every parent scribbles on the same page at the same time. Old notes pile up. Two clerks try to write in the same line together and the ink smears. Nobody can tell whose change is whose.

That notebook is your DbContext. It is a small workspace for one job — one "unit of work." You open it, do your task, save, and close it. The big question of this article is simple: when should the notebook be opened, and when should it be closed? That timing is called the DbContext lifetime.

Getting the lifetime right keeps your app fast, correct, and free of strange bugs. Getting it wrong leads to memory leaks, mixed-up data, and crashes. Let us learn how to do it well.

What a DbContext actually is

A DbContext is the main object you use in Entity Framework Core to talk to your database. It does three big jobs:

  1. It opens and manages the database connection.
  2. It tracks changes to your objects (which ones are new, edited, or deleted).
  3. It turns your LINQ queries into SQL and runs them.

Because it tracks changes, a DbContext slowly fills up with data as you use it. It is meant to be short-lived. You create it, do one job, call SaveChanges, and throw it away. The official guidance from Microsoft is clear: a DbContext instance is designed for a single unit of work, so its lifetime is usually very short.

Figure 1: The normal life of one DbContext. It is born, does one job, saves, and is disposed.

The three lifetimes in dependency injection

In ASP.NET Core, you usually do not create a DbContext by hand. Instead you register it once, and the dependency injection (DI) container hands one to you when you need it. DI supports three lifetimes. Knowing them is the key to this whole topic.

LifetimeHow many instancesGood for DbContext?
TransientA new one every single time it is asked forRarely — wastes connections
ScopedOne per web request (per DI scope)Yes — the default and best choice
SingletonOne for the whole app, foreverNo — dangerous and not thread-safe

Let us picture the difference between these three with a simple frame.

Choosing a lifetime

Transient
Scoped
Singleton

Steps

1

Transient

New each request for it

2

Scoped

One per web request

3

Singleton

One for the whole app

Match the lifetime to how long the work should live.

Why scoped is the right default

When you call AddDbContext, EF Core registers your context as scoped by default. A "scope" in a web app is one HTTP request. So each incoming request gets its own fresh DbContext, and that context is automatically disposed when the request finishes.

This is safe for two reasons. First, within a single request, ASP.NET Core normally runs your code on one thread at a time, so two pieces of code do not fight over the same context. Second, because every request gets a separate scope, request A and request B never share a context. It is like every parent getting their own clean notebook page.

// Program.cs — the standard, recommended setup
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default")));
 
// AppDbContext is now registered as "scoped" automatically.

Now any controller, minimal API handler, or service can simply ask for the context in its constructor, and DI will give it the one that belongs to the current request.

public class OrdersController : ControllerBase
{
    private readonly AppDbContext _db;
 
    // DI gives us the scoped context for THIS request.
    public OrdersController(AppDbContext db)
    {
        _db = db;
    }
 
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
    {
        var order = await _db.Orders.FindAsync(id);
        return order is null ? NotFound() : Ok(order);
    }
}

Notice we never call new and we never call Dispose. The container handles both. This is the cleanest path for most applications.

What goes wrong with the other lifetimes

It helps to see the danger so you remember to avoid it.

Singleton: the smeared notebook

If you register your DbContext as a singleton, every request shares one context for the entire life of the app. This breaks in several ways:

  • It is not thread-safe. Two requests can hit the same context at the same time and corrupt its internal state, throwing errors like "A second operation was started on this context before a previous operation completed."
  • It leaks memory. The change tracker keeps every entity it has ever seen. Over hours and days, that pile grows without limit.
  • Stale data. Because nothing is ever disposed, old cached entities stick around and hide newer database values.

Transient: too many notebooks

A transient context is created fresh every time it is injected. That sounds safe, but it causes a subtle bug: if two different services in the same request each get their own transient context, they cannot see each other's tracked changes. You think you are saving one unit of work, but you are really splitting it across separate contexts. You also open and close more database connections than you need.

Figure 2: How each lifetime maps requests to context instances. Scoped gives one clean context per request.

The most common real bug: parallel queries

Here is a mistake that even experienced developers make. A single DbContext is not thread-safe. That means you cannot run two queries on the same context at the same time. This code looks clever but is broken:

// WRONG — both tasks use the SAME context in parallel.
var customersTask = _db.Customers.ToListAsync();
var ordersTask = _db.Orders.ToListAsync();
 
// Boom: "A second operation was started on this context..."
await Task.WhenAll(customersTask, ordersTask);

The fix is to await one at a time, or to give each parallel job its own context (more on that with IDbContextFactory below).

// CORRECT — one operation finishes before the next starts.
var customers = await _db.Customers.ToListAsync();
var orders = await _db.Orders.ToListAsync();

The rule to memorize: one DbContext, one operation at a time. If you truly need parallelism, you need more than one context.

DbContext pooling: reusing the notebooks

Creating a brand-new DbContext for every request is cheap, but for very busy apps even that small cost adds up. DbContext pooling is an optimization. Instead of throwing the notebook away after each request, the framework resets it and keeps it in a pool to reuse for the next request. This avoids the setup work of building a context from scratch.

You turn it on by swapping AddDbContext for AddDbContextPool:

// Pooling: reuse a set of context instances instead of always
// building new ones. poolSize defaults to 1024.
builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default")),
    poolSize: 128);

Your code in controllers and services does not change at all. From the outside it still looks scoped — one context per request. The only difference is that under the hood the context might be a recycled one that got cleaned up first.

Figure 3: With pooling, a finished context is reset and returned to the pool instead of being destroyed.

The one big warning about pooling

Pooled contexts are reused, so any state you store inside the context survives into the next request. This is dangerous if your context holds per-request data, such as the current user's id or a tenant id set in the constructor or OnConfiguring. The constructor logic runs only when the instance is first created, not on every reuse, so the second request could end up with the first request's tenant.

The table below sums up when pooling is a good fit.

SituationUse pooling?Reason
High-traffic API, plain contextYesSaves setup cost on every request
Context stores tenant id or user idNoPooled instances keep old state
Low-traffic internal toolOptionalThe gain is usually tiny
Context injects per-request servicesBe carefulCaptured services may go stale

If you are not sure, start without pooling. Add it later only when you have measured a real performance need.

Should you enable pooling?

Per-request state?
No state
Enable pool
Keep scoped

Steps

1

Per-request state?

Tenant or user in context

2

No state

Context is stateless

3

Enable pool

Use AddDbContextPool

4

Keep scoped

Stay with AddDbContext

A quick decision path for AddDbContextPool.

DbContext outside a web request: the factory

Scoped lifetime works because a web request creates a scope. But what about a console app, a background worker, a Blazor Server component, or a place where you need several contexts at once? There is no per-request scope to lean on. For these, EF Core gives you IDbContextFactory.

A factory lets you create a context on demand and control exactly when it is disposed. You register it like this:

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default")));

Then you inject the factory and create a short-lived context inside a using block, so it is disposed the moment your unit of work ends:

public class NightlyReportJob
{
    private readonly IDbContextFactory<AppDbContext> _factory;
 
    public NightlyReportJob(IDbContextFactory<AppDbContext> factory)
    {
        _factory = factory;
    }
 
    public async Task RunAsync()
    {
        // Create a fresh context just for this job.
        await using var db = await _factory.CreateDbContextAsync();
 
        var count = await db.Orders.CountAsync();
        Console.WriteLine($"Orders today: {count}");
        // 'await using' disposes the context here automatically.
    }
}

The factory is also the clean way to do true parallel work. Because each task creates its own context, there is no shared state and no thread-safety problem:

// Safe parallelism: each task gets its OWN context.
async Task<int> CountAsync(IDbContextFactory<AppDbContext> f)
{
    await using var db = await f.CreateDbContextAsync();
    return await db.Orders.CountAsync();
}
 
var totals = await Task.WhenAll(
    CountAsync(_factory),
    CountAsync(_factory));

This is the correct version of the broken parallel example we saw earlier. Two contexts, two connections, zero clashes.

Figure 4: With a factory, each background task owns a separate context, so parallel work is safe.

A simple decision guide

Putting it all together, here is how to pick the right approach for your situation.

Picking your DbContext approach

Web app
Console or worker
Need many at once
Heavy traffic

Steps

1

Web app

Use AddDbContext (scoped)

2

Console or worker

Use AddDbContextFactory

3

Need many at once

Use the factory

4

Heavy traffic

Consider AddDbContextPool

From your app type to the registration method.
  • Building a normal web API or MVC app? Use AddDbContext. The scoped default is correct, safe, and simple.
  • Writing a console app, background service, or running parallel queries? Use AddDbContextFactory and a using block.
  • Have a high-traffic, stateless context and proven need for speed? Add AddDbContextPool, but watch out for stored per-request state.
  • Tempted by singleton or transient for the context? Almost never the right call. Step back and use scoped or the factory instead.

A note on disposal

You may wonder: "Do I need to call Dispose myself?" In a web app with AddDbContext, the answer is no — the DI container disposes the scoped context for you when the request ends. With the factory, yes — wrap each context in using or await using so it is disposed when your job finishes. Forgetting to dispose factory-created contexts is a common cause of leaked connections.

// Factory contexts: always dispose them yourself.
await using var db = await _factory.CreateDbContextAsync();
// ... use db ...
// disposed right here, automatically, thanks to 'await using'.

Quick recap

  • A DbContext is a small, short-lived workspace for one unit of work. Open it, do the job, save, close it.
  • A single DbContext is not thread-safe. Never run two operations on it at the same time. One context, one operation at a time.
  • In web apps, register with AddDbContext. The default scoped lifetime gives one fresh context per request and disposes it for you. This is the safe, recommended default.
  • Avoid singleton (not thread-safe, leaks memory) and transient (splits your unit of work) for the context.
  • DbContext pooling (AddDbContextPool) reuses contexts to save setup cost in busy apps. Do not use it if your context stores per-request state like a tenant id.
  • For console apps, background services, Blazor Server, and parallel work, use IDbContextFactory and dispose each context with a using block.
  • Let DI dispose scoped contexts; you dispose factory-created ones.

References and further reading

Related Posts