Everything EF Core does — querying, tracking, saving — happens through one object. Understanding its lifetime is the single most important EF Core skill.
Every EF Core example you've seen so far used a mysterious context variable — context.Products.Where(...). It's time to meet it properly, because it's not a minor supporting character. It's the object that everything else in EF Core revolves around: it opens the connection, tracks the objects you've loaded, remembers what changed, and turns that into SQL when you ask it to save. Get its lifetime wrong — hold onto it too long, or share it in the wrong place — and you'll hit some of the most common and most confusing EF Core bugs there are.
In this lesson, you'll learn what a DbContext actually represents, what DbSet<T> is, why a context is meant to be short-lived, and how to register one for dependency injection with AddDbContext.
A DbContext represents one working session with the database — a single, bounded unit of work. You use it to query for data, make changes to the objects you got back, and then save those changes, all within that one session. When the session is over, the context is disposed, and a fresh one is created for the next unit of work.
DbContext is the class you derive from to describe your application's data model and interact with the database. It combines three responsibilities: it's a connection factory (obtaining a pooled ADO.NET connection when needed), a unit of work (tracking every entity it has loaded or been given, and what's changed about each one), and a query gateway (exposing your entity types as queryable DbSet<T> properties).
public class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
}Notice the primary constructor syntax — ShopDbContext(DbContextOptions<ShopDbContext> options) — accepting its configuration (connection string, provider) as a constructor parameter, and passing it straight to the base DbContext class. This is the modern, idiomatic way to write a context in current C#.
Each DbSet<T> property represents one entity type's table. It implements IQueryable<T>, so you can run LINQ against it directly — context.Products.Where(...) — and it's also the entry point for adding new rows (context.Products.Add(newProduct)) and removing existing ones (context.Products.Remove(product)).
Say you load a Product, change its Price property in memory, and want to save that back to the database. With raw ADO.NET, you'd need to remember the original value, compare it to the new one, and hand-build an UPDATE ... SET Price = @newPrice WHERE Id = @id statement yourself — for every property that might have changed, on every entity you're saving. Do this across dozens of entity types and it becomes a huge amount of repetitive, error-prone bookkeeping.
DbContext solves this by remembering, for every entity it has handed you or been given, what that entity looked like when it was first loaded (or added). When you call SaveChanges(), it compares current values against those remembered originals and generates exactly the INSERT/UPDATE/DELETE statements needed — you never write that comparison logic yourself.
Product product = await context.Products.FirstAsync(p => p.Id == id);
product.Price = 29.99m;
await context.SaveChangesAsync();
Standalone, outside of DI, you'd create and dispose a context explicitly:
DbContextOptions<ShopDbContext> options = new DbContextOptionsBuilder<ShopDbContext>()
.UseSqlServer(connectionString)
.Options;
await using ShopDbContext context = new(options);
Product product = await context.Products.FirstAsync(p => p.Id == productId);
product.Price = 29.99m;
await context.SaveChangesAsync();
// context.DisposeAsync() runs here — the unit of work endsIn an ASP.NET Core application, you almost never write this setup code yourself — you register the context once at startup, and DI hands you a ready-to-use instance per request, which is exactly what the rest of this lesson covers.
In an ASP.NET Core Web API, a controller endpoint that updates a product's price looks like this — the DbContext arrives fully configured via constructor injection, exactly like the services from the Dependency Injection lessons:
[ApiController]
[Route("api/products")]
public class ProductsController(ShopDbContext context) : ControllerBase
{
[HttpPut("{id}/price")]
public async Task<IActionResult> UpdatePrice(int id, [FromBody] decimal newPrice)
{
Product? product = await context.Products.FindAsync(id);
if (product is null)
return NotFound();
product.Price = newPrice;
await context.SaveChangesAsync();
return NoContent();
}
}ASP.NET Core's dependency injection container creates a new ShopDbContext for this request, hands it to the controller, and disposes it automatically once the request finishes — one context, one request, one unit of work. The next request gets its own brand-new context, with no leftover tracked entities from this one.
In Program.cs, register the context with AddDbContext, pointing it at a provider and connection string:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("ShopDb")));AddDbContext<T> registers your context type — and behind the scenes, DbContextOptions<T> too — with the DI container using the Scoped lifetime. If "Scoped" doesn't immediately ring a bell, that's exactly the lifetime you met in the "Service Lifetimes" lesson: one instance per web request, shared by everything within that request, disposed at the request's end.
A DbContext is like a single shopping trip with a cart. You walk in, pick items off the shelf (query), put a few back and swap others (change them in memory), and at checkout the register tallies up exactly what's different from what you walked in with (SaveChanges()). When you leave the store, the cart is returned — it doesn't follow you home, and it doesn't remember your next trip.
Keeping one context alive for the whole application, instead, would be like never returning the cart — dragging an ever-growing pile of items from every trip you've ever made, and trying to use the same cart simultaneously with every other shopper in the store. That's the trouble a Singleton-lifetime context would cause, and exactly why Scoped — one cart per trip — is the right model.
This instinct mirrors the one from the connections lesson — and the answer is the same, only more so: a DbContext is not just a connection, it's a connection plus an ever-growing collection of tracked entities and their snapshots. A long-lived context accumulates tracked state indefinitely, which is both a memory concern and a source of stale-data bugs — not efficiency.
context.Products is not a pre-loaded list of every product — it's an IQueryable<Product> representing "the Products table, queryable." Nothing runs against the database until you materialize it with something like .ToListAsync(), .FirstAsync(), or a foreach — the exact deferred-execution behavior from the LINQ lessons, now generating SQL instead of running in-memory.
builder.Services.AddSingleton<ShopDbContext>(...) — this causes thread-safety exceptions the moment two requests use it concurrently, and lets tracked entities pile up forever. Use AddDbContext, which registers it Scoped — the correct, supported lifetime.
A background service or other Singleton-lifetime class taking a ShopDbContext directly in its constructor — this either throws at startup or silently captures the very first request's context forever, exactly the "captive dependency" problem from the Service Lifetimes lesson. Inject IServiceScopeFactory (or IDbContextFactory<T>) instead, and create a new scoped context each time the background service needs to do work.
private static readonly ShopDbContext _context = new(...); at the class level, reused across every call. This has all the same problems as a Singleton registration, just without DI's help managing it. Let DI create and dispose one context per request/scope — don't manually manage a shared instance.
You've seen why a DbContext's lifetime matters so much. Let's confirm the reasoning stuck.
1. Why does AddDbContext register a context with the Scoped lifetime by default, rather than Singleton?
Correct: B
Why B is correct: A DbContext is not thread-safe and tracks every entity it touches. A Singleton instance shared across every request would break under concurrent use and would never stop accumulating tracked state. Scoped — one instance per request — matches the "unit of work" model a context is designed around.
Why A is incorrect: Resolution speed isn't the reason — the reasoning is about correctness (thread-safety and tracked-state accumulation), not raw performance of DI resolution.
Why C is incorrect: It's technically possible to register a DbContext as Singleton — it's just a serious mistake, not a compiler or framework-enforced impossibility.
Why D is incorrect: Scoped isn't a universal default — different services get different lifetimes based on their own characteristics, as covered in the Service Lifetimes lesson. AddDbContext specifically chooses Scoped because of how a context behaves.
Reinforcement: The Scoped lifetime for DbContext isn't an arbitrary default — it directly reflects what a context is: a single unit of work, not a long-lived shared resource.
2. What does context.Products represent immediately after a DbContext is created, before any query method like ToListAsync() is called?
Correct: C
Why C is correct: DbSet<T> implements IQueryable<T> — it represents the query "all of the Products table," but nothing executes against the database until the query is materialized (ToListAsync, FirstAsync, a foreach, etc.), exactly like the deferred-execution LINQ you learned earlier.
Why A is incorrect: This would mean every DbSet access loads the entire table immediately — that would be extremely wasteful and isn't how EF Core works.
Why B is incorrect: It's fully usable right away — you can chain LINQ operators onto it, add filters, etc. — it's just not yet executed against the database.
Why D is incorrect: EF Core doesn't maintain an automatic persistent cache of table contents between application runs — each DbSet access represents a fresh queryable view of the table.
Reinforcement: DbSet<T> is a query starting point, not a pre-loaded snapshot — the actual database round-trip happens only when you materialize the query.
3. A background service registered as a Singleton needs to run a database query every hour. What's the correct way for it to get a DbContext?
Correct: B
Why B is correct: A Singleton service outliving a Scoped DbContext creates the "captive dependency" problem — the only correct fix is to create a new scope (and a new context within it) each time the background service actually needs to do work, then dispose it when done.
Why A is incorrect: This either throws a validation error at startup (with scope validation enabled) or silently captures one context for the Singleton's entire lifetime — exactly the captive dependency bug this pattern is meant to avoid.
Why C is incorrect: Changing the DbContext's own lifetime to Singleton reintroduces all the thread-safety and unbounded-tracking problems this lesson covered — it doesn't fix anything, it just moves the mismatch to the wrong side.
Why D is incorrect: This is functionally the same mistake as A — a manually-managed long-lived context instead of one obtained through DI, with the same problems.
Reinforcement: When a longer-lived component needs Scoped-lifetime work done periodically, create a fresh scope for each unit of work rather than trying to hold onto one scoped instance.
You now understand what a DbContext is, what DbSet<T> gives you, and why its Scoped lifetime matters. Next up: defining the entity classes that make up your data model.
dotnetmadeeasy.com — Learn C# and .NET, the right way.