Why this post exists
Entity Framework Core is the data layer of most .NET applications, and it is also where most .NET performance problems live. Almost all of them trace back to one object that people use daily and rarely understand: the DbContext. This post explains what it is, what it does behind the scenes, and the mistakes that follow when it is misunderstood.
What Entity Framework Core is
Entity Framework Core (EF Core) is an object-relational mapper, or ORM. It translates between two worlds that do not speak the same language: C# objects on one side, SQL tables and rows on the other.
The analogy: a translator at a meeting. You speak C# (classes, properties, LINQ). The database speaks SQL. EF Core sits between you, turning db.Customers.Where(c => c.Name == "Asha") into a SELECT statement, and turning the rows that come back into Customer objects.
The tradeoff: you write far less data-access code and get compile-time checking, but you give up direct control of the SQL. Usually that is a good deal. It becomes a bad one when you do not look at what SQL is being generated.
What a DbContext is
The DbContext is the main class you work with in EF Core. A common mistake is to treat it as a database connection or as a global gateway to the database. It is neither.
The analogy: a clerk at a warehouse counter, holding a clipboard. You ask the clerk for items (queries). The clerk fetches them and writes down on the clipboard exactly what was handed over and what it looked like (the change tracker). You then change some items, add new ones, discard others. Nothing in the warehouse changes yet. When you say "submit", the clerk reads the clipboard, works out what is different, and makes one trip to the warehouse to apply everything together (SaveChanges).
That gives the DbContext three jobs:
- Query: turns LINQ into SQL and runs it.
- Track: remembers every entity it loaded, and the state each one was in when loaded.
- Unit of work: SaveChanges writes all the tracked changes as a single transaction.
The word that matters in all of this is short-lived. The clerk's clipboard is for one job. Hand the same clipboard around all day and it fills up, gets out of date, and eventually two people write on it at once.
The example: customers and orders
One small model used throughout: a customer has many orders.
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Order> Orders { get; set; } = new();
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public decimal Amount { get; set; }
public DateTime CreatedUtc { get; set; }
}
These are plain C# classes, called entities. Each becomes a table. Id is the primary key by convention, and CustomerId is the foreign key to Customer.
The DbContext:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.Property(c => c.Name).HasMaxLength(200).IsRequired();
modelBuilder.Entity<Order>()
.Property(o => o.Amount).HasPrecision(18, 2);
}
}
Piece by piece: it inherits from DbContext. The constructor takes DbContextOptions, which carries the provider and connection string, so the class itself never hard-codes either. Each DbSet is a shelf in the warehouse: one per table you want to query. OnModelCreating is where you describe details the conventions cannot guess, such as maximum lengths and decimal precision.
Registering it in Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
AddDbContext registers AppDbContext with a Scoped lifetime by default. That means one instance per web request (or one per function invocation in Azure Functions). When a controller or service asks for AppDbContext in its constructor, the container supplies the instance for that request. When the request ends, the context is disposed. That default is correct, and the next section explains why.
For Azure SQL in production, keep the connection string in configuration, not code: an app setting, a Key Vault reference, or a passwordless connection using Managed Identity.
Why DbContext must be Scoped
- It is not thread-safe. Two operations using the same context at the same moment will throw or corrupt state.
- The change tracker only grows. A long-lived context keeps references to every entity it ever loaded, so memory climbs.
- Stale data. A tracked entity is not refreshed from the database, so a long-lived context keeps showing old values.
- One unit of work. A request is naturally one unit of work. SaveChanges at the end saves everything or nothing.
Registering it as Singleton, or storing it in a static field, breaks all four. It is also the root of the captive dependency bug covered in my Azure Functions dependency injection post: a Singleton repository holding on to a Scoped DbContext.
Querying: LINQ becomes SQL
var recent = await db.Orders
.Where(o => o.CreatedUtc > DateTime.UtcNow.AddDays(-7))
.OrderByDescending(o => o.CreatedUtc)
.Take(20)
.ToListAsync();
Nothing runs until ToListAsync. Before that, the expression is an IQueryable, a description of a query. When you materialise it, EF Core generates one SQL statement with the WHERE, ORDER BY and TOP, and only the matching rows come back. This is deferred execution, and it is why the order of your LINQ calls matters: everything before ToListAsync becomes SQL, everything after runs in memory.
The change tracker and entity states
Each entity the context knows about has a state:
- Detached: the context knows nothing about it.
- Added: new, will be INSERTed on save.
- Unchanged: loaded, nothing different yet.
- Modified: loaded, then changed, will be UPDATEd.
- Deleted: will be DELETEd.
var order = await db.Orders.FirstAsync(o => o.Id == 42); // Unchanged
order.Amount = 120.00m; // now Modified
db.Orders.Add(new Order { CustomerId = 1, Amount = 30m,
CreatedUtc = DateTime.UtcNow }); // Added
await db.SaveChangesAsync(); // one transaction: 1 UPDATE + 1 INSERT
Steps behind that: the clerk fetched order 42 and noted its original values. You changed Amount. Calling SaveChangesAsync, the clerk compared the clipboard with the current values, found one changed property, and generated an UPDATE touching only the Amount column. The new order became an INSERT. Both ran in one transaction, so either both succeed or neither does.
Tracking versus AsNoTracking
Tracking has a cost: memory for the snapshot, and time for comparison. If you only read, you do not need it.
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync();
With AsNoTracking() the clerk hands over the items without writing on the clipboard. It is faster and lighter. The catch: changes to those objects are not saved, because nobody is watching them. Use it for every read-only endpoint.
Bad version versus good version: five mistakes
Each mistake follows the same pattern: the bad version, the issues, the good version.
Mistake 1: the N+1 query
Bad version:
[HttpGet("summaries")]
public async Task<IActionResult> GetSummaries()
{
var customers = await _db.Customers.ToListAsync(); // 1 query
var result = new List<object>();
foreach (var c in customers)
{
var count = await _db.Orders
.CountAsync(o => o.CustomerId == c.Id); // N queries
result.Add(new { c.Name, OrderCount = count });
}
return Ok(result);
}
Issues:
- One query for the customers plus one more for each customer. With 500 customers that is 501 round trips to the database.
- Every customer is loaded and tracked even though only the name is used.
- It looks harmless in development with five rows, and falls over in production.
Good version:
public record CustomerSummary(int Id, string Name, int OrderCount, decimal TotalAmount);
[HttpGet("summaries")]
public async Task<IActionResult> GetSummaries()
{
var result = await _db.Customers
.AsNoTracking()
.Select(c => new CustomerSummary(
c.Id,
c.Name,
c.Orders.Count,
c.Orders.Sum(o => o.Amount)))
.ToListAsync();
return Ok(result);
}
A single query, a join and aggregates done by the database, and only four columns come back. If you genuinely need whole entities with their children, use .Include(c => c.Orders), which also loads in one query. Prefer projection when you only need some fields.
Mistake 2: filtering in memory
Bad version:
var big = (await _db.Orders.ToListAsync())
.Where(o => o.Amount > 1000)
.ToList();
Issues:
- ToListAsync runs first, so every row in the table is pulled across the network and tracked.
- The Where then runs in application memory.
- It works on a small table and quietly becomes a memory and speed problem as the table grows.
Good version:
var big = await _db.Orders
.AsNoTracking()
.Where(o => o.Amount > 1000)
.ToListAsync();
The Where is now part of the SQL, so the database does the filtering and only the matching rows travel.
Mistake 3: a shared context across parallel tasks
Bad version:
var tasks = ids.Select(id => _db.Orders.FirstAsync(o => o.Id == id));
var orders = await Task.WhenAll(tasks);
Issues:
- All the queries start at once on one DbContext.
- DbContext is not thread-safe, so EF Core throws an error that a second operation started before the first completed.
- It fails unpredictably, depending on timing.
Good version:
var orders = await _db.Orders
.AsNoTracking()
.Where(o => ids.Contains(o.Id))
.ToListAsync();
One query with an IN clause. If you truly need parallel work, give each task its own context from IDbContextFactory<AppDbContext>.
Mistake 4: SaveChanges inside a loop
Bad version:
foreach (var dto in newOrders)
{
_db.Orders.Add(new Order { CustomerId = dto.CustomerId,
Amount = dto.Amount, CreatedUtc = DateTime.UtcNow });
await _db.SaveChangesAsync(); // one trip per order
}
Issues:
- One database round trip and one transaction per order.
- If the loop fails halfway, some orders are saved and some are not.
- It throws away the unit-of-work idea the DbContext exists for.
Good version:
var orders = newOrders.Select(dto => new Order
{
CustomerId = dto.CustomerId,
Amount = dto.Amount,
CreatedUtc = DateTime.UtcNow
});
_db.Orders.AddRange(orders);
await _db.SaveChangesAsync(); // one transaction, all or nothing
EF Core batches the INSERTs into as few round trips as it can and wraps them in one transaction.
Mistake 5: a long-lived DbContext
Bad version:
builder.Services.AddSingleton<AppDbContext>(); // or a static field
Issues:
- Every request shares one context, so concurrent requests collide.
- The change tracker accumulates every entity ever loaded, so memory keeps rising.
- Entities become stale because tracked objects are not refreshed.
- A failed SaveChanges leaves bad tracked state that affects later requests.
Good version:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
Scoped by default: a fresh clerk and a clean clipboard for every request. If you must use a context from a Singleton, inject IDbContextFactory<AppDbContext> and create a short-lived context for each operation.
Every issue at a glance
- N+1: one query per row instead of one query overall
- N+1: whole entities loaded and tracked when only a few columns are needed
- N+1: invisible in development, painful in production
- In-memory filtering: the whole table crosses the network
- In-memory filtering: Where runs in application memory
- In-memory filtering: gets worse as the table grows
- Shared context across tasks: concurrent operations on one context
- Shared context across tasks: DbContext is not thread-safe
- Shared context across tasks: timing-dependent failures
- SaveChanges in a loop: a round trip and transaction per item
- SaveChanges in a loop: partial saves on failure
- SaveChanges in a loop: ignores the unit of work
- Long-lived context: concurrent requests collide
- Long-lived context: memory grows through the change tracker
- Long-lived context: stale entities
- Long-lived context: failed saves poison later requests
Migrations: changing the database safely
When your entities change, the database must change with them. A migration is a C# file describing that change, generated by comparing your model with the last snapshot.
dotnet ef migrations add AddOrderCreatedUtc
dotnet ef database update
The first command creates the migration. The second applies pending migrations to the database named in your connection string. That is fine for local development.
For production, avoid running Database.Migrate() at application startup. In a scaled-out app, several instances may try to migrate at the same time, and a failed migration takes the whole app down with it. Instead, generate a script and review it:
dotnet ef migrations script --idempotent -o migrate.sql
The idempotent script only applies migrations that have not been applied, so it is safe to run more than once. Run it from your pipeline as its own step, before the new version of the app starts. Also remember that dropping or renaming a column is a destructive change: deploy in two steps so the old app version keeps working while the new one rolls out.
Bulk updates without loading
Changing many rows by loading them all into the tracker is wasteful. For bulk changes, EF Core can run a single UPDATE or DELETE directly:
await _db.Orders
.Where(o => o.CreatedUtc < DateTime.UtcNow.AddYears(-3))
.ExecuteDeleteAsync();
Notice what is missing: no entities loaded, no tracking, no SaveChanges. The statement executes immediately, so use it deliberately.
Testing code that uses DbContext
You have two honest options. Put the data access behind a repository interface and mock it for unit tests of business logic, as in my repository pattern post. And for tests that check the queries themselves, use a real database engine, for example SQLite in memory or a disposable SQL Server container. The EF Core InMemory provider is tempting, but it does not behave like a relational database: it ignores constraints and does not translate queries to SQL, so tests can pass when production would fail.
Problem scenario and strategy
The problem: an API endpoint listing customers with order totals takes eight seconds in production. It was fast in development.
The strategy:
- Turn on SQL logging, or look at the dependency calls in Application Insights, and count the queries one request makes.
- Spot the pattern: one query, then the same query repeated once per customer. That is N+1.
- Check whether anything is filtered after ToList. If yes, move the Where before it.
- Replace the loop with a single projection using Select, returning a small DTO.
- Add AsNoTracking, since the endpoint is read-only.
- Check the database for an index on the foreign key and on any column filtered often. Foreign keys get one by convention, but verify.
- Re-test with production-sized data and compare the query count and time. Eight seconds becomes milliseconds.
Key lessons
- EF Core translates between C# objects and SQL; look at the SQL it generates
- DbContext is a short-lived unit of work with three jobs: query, track, save
- Changes are only written when SaveChanges is called, in one transaction
- Register DbContext as Scoped; never Singleton or static
- DbContext is not thread-safe: one operation at a time, or use IDbContextFactory
- Use AsNoTracking for read-only queries
- Avoid N+1 with projection or Include
- Keep Where before ToListAsync so the database filters
- Batch changes and call SaveChanges once
- Apply migrations from a reviewed, idempotent script in production
Summary
The DbContext is a clerk with a clipboard: it fetches, remembers what it handed out, notices changes, and submits them together. Keep it short-lived and scoped, read with AsNoTracking, let the database do the filtering and aggregating, save in batches, and treat migrations as a deployment step rather than a startup side effect. Most EF Core performance problems stop being mysterious once you see the SQL behind the LINQ.
Top comments (0)