DbContext is the heart of every EF Core application. It's the object you instantiate, the object you query through, and the object you call SaveChanges on. Under the hood it plays three roles at once: a Unit of Work that batches your changes into one transaction, an Identity Map that keeps a single in-memory copy of each row you've touched, and a change tracker that figures out which entities are new, modified, or deleted. This lesson covers how to define a DbContext, configure its connection, register it with dependency injection, manage its lifetime, and call SaveChanges to push your changes to the database.
The introduction lesson framed EF Core as an ORM that maps C# classes to database tables. DbContext is the runtime object that does the mapping. When you read an entity through a DbContext, the context keeps a reference to it. When you change a property on that entity, the context notices. When you call SaveChanges, the context translates every change it has tracked into INSERT, UPDATE, and DELETE statements, wraps them in a transaction, and sends them off.
Three patterns are baked into DbContext:
SaveChanges calls are one logical unit. They either all succeed (commit) or all fail (rollback). You don't open transactions by hand.DbContext, every row in a table has at most one materialized instance. If you query the same Product twice through the same context, you get the same C# object both times.The diagram shows the flow for one logical operation. Your code interacts with a DbContext. Behind that public surface the context coordinates a change tracker and an identity map. When SaveChanges runs, all tracked changes are batched into a single transaction and sent to the database. The rows that come back from queries flow into the identity map so the same entity is never materialized twice in one context.
This is why a DbContext is supposed to be short-lived. It accumulates state. Every entity you load adds to the change tracker. Hold one context for hours and you've built an in-memory replica of half your database, which is exactly what you don't want.
You never use DbContext directly. You inherit from it and expose your tables as DbSet<T> properties.
ShopContext is the type your application code uses. The DbSet<Product> and DbSet<Customer> properties are what you query and modify; EF Core treats each one as a table. The OnConfiguring override tells EF Core which database provider to use and where the database lives.
The => Set<Product>() expression body is the modern style. Older code uses auto-properties (public DbSet<Product> Products { get; set; }) and EF Core wires them up by convention. Both forms work, but the expression-bodied form is slightly more explicit and plays nicely with nullable reference types.
There's a quiet rule here: a DbSet<T> doesn't hold rows. It's a query handle. context.Products doesn't fetch any data on its own. It exposes methods (ToList, FirstOrDefault, Where, Add, Remove) that either build a query against the database or feed the change tracker.
OnConfiguring is where you wire the context to a database. EF Core ships providers as separate NuGet packages: Microsoft.EntityFrameworkCore.Sqlite for SQLite, Microsoft.EntityFrameworkCore.SqlServer for SQL Server, Npgsql.EntityFrameworkCore.PostgreSQL for PostgreSQL, and so on. The provider supplies the UseXxx extension method.
The connection string is the provider-specific bit. For SQLite, Data Source=shop.db points to a file. For SQL Server it would be Server=...;Database=...;Trusted_Connection=true;. For PostgreSQL it would be Host=...;Database=...;Username=...;Password=...;. EF Core itself doesn't parse the string, it hands it to the provider.
OnConfiguring runs once per context instance, right after the constructor. If you ever instantiate a context with new ShopContext(), this is where its options come from. That's how the simplest "open a console app, write a script" flow works without involving dependency injection.
In a real application, hardcoding a connection string here is rare. You usually want the connection string to come from configuration, and you want DI to construct the context for you. That's where OnConfiguring steps back and the DI-based registration takes over.
OnModelCreating is the other override commonly defined on a DbContext. It's where you configure how entities map to tables: column types, constraints, indexes, relationships, table names. EF Core has sensible defaults (a class called Product becomes a table called Products, a property called Name becomes a column called Name), so for simple models you don't need to override it at all.
That's the entire shape: get an EntityTypeBuilder<T> from modelBuilder.Entity<T>(), then chain configuration calls. The Fluent API has a method for every mapping concern: HasKey, HasIndex, HasMany, HasOne, HasColumnType, IsRequired, HasMaxLength, and so on.
We won't go further than this in the current lesson. The _Code-First Approach_ lesson covers entity mapping in depth, including when to use data annotations versus the Fluent API, how to configure keys and indexes, and how to handle owned types and value conversions. For now, treat OnModelCreating as the hook where customization happens when convention isn't enough.
A DbContext is not thread-safe. It holds mutable state (the change tracker, the identity map, the open connection) and concurrent access to it from multiple threads will, in the best case, throw an exception, and in the worst case corrupt the tracker's internal state. The Microsoft documentation is blunt about this: "DbContext is not thread-safe. Do not share contexts between threads."
The natural shape of a DbContext is "create one, do a unit of work, dispose it". For a console script that's the entire lifetime of the program. For an ASP.NET Core web request, it's the lifetime of one request: one context, one transaction-worth of changes, then disposal.
| Lifetime | Behavior | Use For | Risk |
|---|---|---|---|
| Scoped | One instance per scope (per HTTP request in ASP.NET Core). | Web apps, the default. | None when used correctly. |
| Transient | New instance every time it's resolved from the container. | Rare; specific cases where you need a brand-new context inside a scope. | If two services in the same request resolve it, they get different contexts and won't see each other's changes. |
| Singleton | One instance shared by the whole application. | Never for DbContext. | Thread-safety violations, ever-growing change tracker, connection lifetime problems. Will eventually crash or hang. |
The singleton row is the one that bites people. It's tempting because a "shared" context sounds efficient. It isn't. It's wrong. Every request through your web server would share the same change tracker, the same identity map, and the same open connection. The first time two requests hit the context concurrently, you get a InvalidOperationException: A second operation was started on this context instance before a previous operation completed.
The right shape is scoped, which is what AddDbContext gives you by default.
In any ASP.NET Core app (or any app using the generic host), you register the context with the DI container in Program.cs. The three relevant methods are AddDbContext, AddDbContextFactory, and AddDbContextPool.
AddDbContext<ShopContext> does three things. It registers ShopContext with scoped lifetime, it wires the options object you configure here into the context's constructor (so you can remove the OnConfiguring override), and it makes ShopContext injectable into anything else in the container.
For this to work, the context needs a constructor that takes DbContextOptions<ShopContext>:
That constructor is the bridge between DI and the context. EF Core resolves the options from the container, hands them to the base constructor, and the context uses them in place of OnConfiguring. You can still override OnConfiguring if you want, and it runs after the options are applied, but most DI-based apps leave it out entirely.
AddDbContextFactory<ShopContext> registers an IDbContextFactory<ShopContext> instead of the context itself. Inject the factory, call CreateDbContext() whenever you need a fresh context, and dispose it when you're done. This is the right shape when one logical operation needs more than one context (parallel queries, background tasks that outlive the request scope, Blazor Server components that are scoped to the user session rather than the request).
AddDbContextPool<ShopContext> keeps a small pool of context instances and reuses them across requests. It's a performance optimization: creating a DbContext involves resetting its internal state, which has a non-trivial cost on high-throughput servers. Pooling reuses the instances and resets only the parts that need resetting.
Pooling shaves microseconds per request, which adds up at thousands of requests per second. The trade-off is that any state added to the context (like custom fields set in the constructor) won't get reset between requests. Use AddDbContext until context creation is measurably slow.
For most applications, AddDbContext is the right default. Use the factory for multiple concurrent contexts in one operation. Use the pool when context creation is a measurable cost.
Hardcoding a connection string in OnConfiguring works for samples and tests. Real applications keep it in configuration. ASP.NET Core's configuration system reads from appsettings.json, environment variables, and user secrets in a layered way (later sources override earlier ones).
The conventional place for a connection string is the ConnectionStrings section of appsettings.json:
builder.Configuration.GetConnectionString("Shop") is the helper that pulls it out. For different environments, you'd use appsettings.Development.json, appsettings.Production.json, or override via environment variables (ConnectionStrings__Shop=Data Source=prod.db). The double-underscore is how environment variables encode the colon separator in configuration keys.
Production connection strings (especially ones with passwords) don't belong in appsettings.json checked into source control. The two clean options are:
dotnet user-secrets set "ConnectionStrings:Shop" "...". The secrets file lives outside the repo, in your user profile.ConnectionStrings__Shop in the deployment environment (Docker, Kubernetes, App Service, whichever).The configuration system layers them automatically: environment variables override user secrets, which override appsettings.{Environment}.json, which overrides appsettings.json. Your code calls GetConnectionString and doesn't care where the value came from.
Reading data through DbContext is one direction. Writing data goes through three steps: change the tracked entities, call SaveChanges, and let EF Core translate the changes into SQL.
A few things are happening here. Add flags the new product as Added. The query for firstProduct loads a row and the change tracker takes a snapshot of it; mutating Price afterwards marks it as Modified. Remove flags the stale product as Deleted. Until SaveChanges is called, nothing has been sent to the database. The context is just remembering what you intend to do.
SaveChanges is where the magic happens. It walks the change tracker, generates SQL for each non-Unchanged entity, opens a transaction, sends all the statements, commits, and returns the number of affected rows. If anything fails (a constraint violation, a network blip, a concurrency conflict), the transaction rolls back and you get an exception, leaving the database in its original state.
The async version is SaveChangesAsync. In any application that does I/O (web servers, desktop apps, anything with a UI), prefer it. It frees the thread while waiting for the database round-trip, which matters for throughput.
The behavior is identical; only the threading model differs.
Calling SaveChanges once per row (for example, inside a loop that adds 10,000 products) sends 10,000 round-trips to the database. Calling Add 10,000 times and then SaveChanges once sends them in one batch, typically as a single transaction with EF Core's batched INSERT support. The difference is two or three orders of magnitude on real network connections.
Every entity tracked by a DbContext is in exactly one of five states. The change tracker uses these states to decide what SQL to generate at SaveChanges time.
| State | What It Means | Caused By | SQL on Save |
|---|---|---|---|
| Added | New entity, doesn't exist in the database yet. | context.Add(entity) or dbSet.Add(entity). | INSERT |
| Modified | Loaded entity with at least one changed property. | Querying then mutating a property. | UPDATE |
| Deleted | Tracked entity marked for removal. | context.Remove(entity) or dbSet.Remove(entity). | DELETE |
| Unchanged | Loaded entity, no properties have changed. | The default for any entity returned by a query. | None |
| Detached | Not tracked at all. | context.Entry(entity).State = EntityState.Detached or returned from a .AsNoTracking() query. | None |
You can read or override the state through context.Entry(entity).State. That's how you handle disconnected scenarios, for instance, an HTTP API that receives a JSON object representing an updated product. Since the object wasn't loaded through the context, it's Detached. You set the state to Modified and SaveChanges will issue an UPDATE.
The AsNoTracking variant in the table comes up often in read-heavy code paths. By default every query feeds the change tracker, even if you only want to display the data. AsNoTracking skips that overhead. The trade-off, and the reason it's not the default, is that you can't SaveChanges on entities loaded that way.
DbContext implements IDisposable (and IAsyncDisposable in modern EF Core). When you dispose a context, it closes any open database connection, releases the change tracker, and clears its internal caches. Hanging on to undisposed contexts leaks both memory (the tracker keeps growing) and database connections (the pool runs dry).
The idiomatic pattern when you construct the context yourself is using:
using var is C# 8's compact form, equivalent to using (var db = new ShopContext()) { ... }. Either form does the same thing: at the end of the enclosing scope, Dispose runs and the context tears itself down.
When the context comes from DI, you don't dispose it yourself. The container owns its lifetime and disposes it at the end of the scope (the end of the HTTP request, in ASP.NET Core). Calling Dispose on a DI-managed context manually is a bug; it'll be disposed twice.
The ShopContext db parameter is resolved from the request's DI scope. The framework disposes it when the request ends. Your code never sees the disposal call, which is the point.
The pieces come together in a small Minimal API. The context is registered with DI, the connection string comes from configuration, the endpoints inject ShopContext per request, and SaveChangesAsync commits the work.
Every request to this API gets its own ShopContext instance. The framework constructs it (resolving the options from the container), passes it into the endpoint delegate, and disposes it after the response is sent. Concurrent requests get separate contexts, so the thread-safety problem doesn't exist. The EnsureCreated call in the startup scope is a demo shortcut that creates the database file and schema from the model; real applications use migrations.
The Database.EnsureCreated call sits inside app.Services.CreateScope() for a specific reason: the default scope at startup doesn't have one, so resolving a scoped service like ShopContext from the root provider would throw. Creating an explicit scope, resolving the context inside it, and disposing the scope is the standard pattern for any "do this once on startup" work that uses scoped services.
10 quizzes