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.
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:
- It opens and manages the database connection.
- It tracks changes to your objects (which ones are new, edited, or deleted).
- 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.
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.
| Lifetime | How many instances | Good for DbContext? |
|---|---|---|
| Transient | A new one every single time it is asked for | Rarely — wastes connections |
| Scoped | One per web request (per DI scope) | Yes — the default and best choice |
| Singleton | One for the whole app, forever | No — dangerous and not thread-safe |
Let us picture the difference between these three with a simple frame.
Choosing a lifetime
Steps
Transient
New each request for it
Scoped
One per web request
Singleton
One for the whole app
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.
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.
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.
| Situation | Use pooling? | Reason |
|---|---|---|
| High-traffic API, plain context | Yes | Saves setup cost on every request |
| Context stores tenant id or user id | No | Pooled instances keep old state |
| Low-traffic internal tool | Optional | The gain is usually tiny |
| Context injects per-request services | Be careful | Captured 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?
Steps
Per-request state?
Tenant or user in context
No state
Context is stateless
Enable pool
Use AddDbContextPool
Keep scoped
Stay with AddDbContext
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.
A simple decision guide
Putting it all together, here is how to pick the right approach for your situation.
Picking your DbContext approach
Steps
Web app
Use AddDbContext (scoped)
Console or worker
Use AddDbContextFactory
Need many at once
Use the factory
Heavy traffic
Consider AddDbContextPool
- 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
AddDbContextFactoryand ausingblock. - 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
DbContextis a small, short-lived workspace for one unit of work. Open it, do the job, save, close it. - A single
DbContextis 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
IDbContextFactoryand dispose each context with ausingblock. - Let DI dispose scoped contexts; you dispose factory-created ones.
References and further reading
- DbContext Lifetime, Configuration, and Initialization — EF Core (Microsoft Learn)
- Advanced Performance Topics (DbContext Pooling) — EF Core (Microsoft Learn)
- Managing EF Core DbContext Lifetime — TheCodeMan
- How To Manage EF Core DbContext Lifetime — antondevtips
- DbContext Pooling: The Secret Sauce to Faster EF Core Apps — woodruff.dev
Related Posts
DbContext Is Not Thread-Safe: Parallelizing EF Core Queries the Right Way
Learn why EF Core DbContext is not thread-safe and how to run parallel queries safely using IDbContextFactory in .NET 10. Beginner friendly.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
EF Core Migrations: A Detailed Beginner Guide for .NET
Learn EF Core migrations step by step. Add, apply, revert, and ship database changes safely with simple examples, diagrams, tables, and best practices for .NET 10.
Using Multiple EF Core DbContext in a Single Application
Learn how to use multiple EF Core DbContext classes in one .NET app. See when to split, how to register, migrate, and coordinate them with simple examples.
EF Core DbContext Options Explained: A Beginner's Friendly Guide
Learn EF Core DbContext options in simple words: AddDbContext, the options builder, retry on failure, query splitting, logging, lifetimes and pooling, with diagrams and examples.
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.