← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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#.

DbSet<T> — a queryable, trackable collection

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)).

Why Does It Exist?

The Problem — "What Changed?" Is a Hard Question to Answer Manually

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.

The Solution — A Session That Remembers What It Gave You

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.

Big Picture

A DbContext'S THREE JOBS
1. Connection factory
2. Unit of work / change tracker
3. Query gateway

How It Works

A TYPICAL DbContext LIFETIME — ONE UNIT OF WORK
Step 1 — A context instance is created (usually by DI, per request)
Step 2 — Query for the data you need
Product product = await context.Products.FirstAsync(p => p.Id == id);
Step 3 — Make changes in memory
product.Price = 29.99m;
Step 4 — Save
await context.SaveChangesAsync();
Step 5 — The context is disposed; the unit of work is over

Simple Example

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 ends

In 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.

Real-World Example

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.

Registering DbContext with Dependency Injection

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.

Why Scoped is the right default lifetime for a DbContext

Analogy

A Shopping Trip, Not a Permanent Warehouse Key

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.

Under the Hood

WHAT A DbContext ACTUALLY HOLDS ONTO
1. It doesn't open a connection until it needs one
2. The change tracker is an in-memory dictionary of entities and snapshots
3. A context is not thread-safe

Common Confusion

1. "DbContext is just a connection wrapper, so keeping one around should be efficient"

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.

2. "DbSet<T> is the table's data, loaded into memory"

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.

Common Mistakes

Mistake 1 — Registering DbContext as a Singleton for "performance"

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.

Mistake 2 — Injecting a Scoped DbContext into a Singleton service

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.

Mistake 3 — Manually creating and holding a static DbContext field

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.

When Should I Use It?

Rule of thumb: Let one DbContext instance correspond to one unit of work — typically one web request. Register it with AddDbContext and let DI hand it out as Scoped; don't try to reuse a single instance across requests, and don't create a fresh one for every tiny operation within the same unit of work either — you want your related queries and changes within one request sharing the same tracked entities.

Mental Model

DbContext = one shopping trip: query, change, checkout, done.
DbSet<T> = the aisle for one product type — queryable, not pre-loaded.
Scoped = one context per web request — shared within it, gone at the end of it.

Remember: a DbContext is a unit of work, not a long-lived connection manager — treat it like one.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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.