Data access with Entity Framework Core
DbContext lifetime, migrations, loading strategies and the N+1 queries that turn a fast endpoint into a slow one.
Context lifetime and configuration
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("App"),
sql => sql.EnableRetryOnFailure(3)));
// For a read-heavy service that creates a context per operation
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("App")));
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>(e =>
{
e.ToTable("orders");
e.HasKey(o => o.Id);
e.Property(o => o.Total)
.HasConversion<decimal>(m => m, v => v)
.HasColumnType("decimal(18,2)");
e.Property(o => o.RowVersion).IsRowVersion(); // optimistic concurrency
e.HasIndex(o => new { o.CustomerId, o.PlacedAt });
e.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
});
// Apply every IEntityTypeConfiguration<T> in this assembly
b.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}- Scoped lifetime matches the request, which is the right default. A context is not thread safe and must never be shared across concurrent operations.
- Pooling reuses context instances, which helps a service that creates many short-lived contexts, but the context must not hold per-request state in its constructor.
- Queries are not thread safe on one context either:
await Task.WhenAll(db.A.ToListAsync(), db.B.ToListAsync())on the same context is a bug. - Configure the model explicitly. Convention is fine for a prototype and a liability for a schema you will own for years.
- An index on the columns your WHERE and ORDER BY clauses use is worth more than any query rewrite.
Migrations, loading and N+1
# Migrations are code: review them like code
dotnet ef migrations add AddOrderIndex --output-dir Data/Migrations
dotnet ef migrations script --idempotent -o migrate.sql
dotnet ef database update
# See the SQL a query produces, without running the app
dotnet ef dbcontext optimize --output-dir Compiled --namespace App.Compiled// N+1: one query for the orders, then one per customer
var orders = await db.Orders.Where(o => o.PlacedAt > since).ToListAsync();
foreach (var o in orders)
{
var name = (await db.Customers.FindAsync(o.CustomerId)).Name; // N queries
}
// Fixed with projection: one query, only the columns you need
var rows = await db.Orders
.Where(o => o.PlacedAt > since)
.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name))
.AsNoTracking()
.ToListAsync(ct);
// Eager loading when you need the tracked entities
var withItems = await db.Orders
.Include(o => o.Items)
.Where(o => o.Id == id)
.AsSplitQuery() // avoids the cartesian explosion
.FirstOrDefaultAsync(ct);
// Explicit loading for a small, known set of parents
await db.Entry(order).Collection(o => o.Items).LoadAsync(ct);
// AsNoTracking for read-only work: no change tracker, less memory
var report = await db.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Fulfilled)
.GroupBy(o => o.Currency)
.Select(g => new { g.Key, Total = g.Sum(o => o.Total) })
.ToListAsync(ct);| Symptom | Likely cause | Fix |
|---|---|---|
| One query per row in a log | Lazy loop access | Project or Include |
| Query runs but returns nothing | Client-side evaluation of an untranslatable method | Rewrite with translatable operations |
| Slow query with many joins | Cartesian explosion from several collections | AsSplitQuery() |
| Updates do not persist | A context created outside the request scope | Use the scoped context or save explicitly |
DbUpdateConcurrencyException | A row version was changed by another writer | Reload and retry, or return 409 |
| Truncation errors on a decimal | Column type inferred as float | Configure precision explicitly |
⚠️
A query that compiles can still be evaluated on the client, which silently downloads a whole table. Set a warning or a hard failure for client evaluation in development, and read the generated SQL for any query on a hot path at least once.
FAQ
Should I use migrations or hand-written SQL?
Migrations for the schema's normal evolution so it stays in source control, hand-written SQL for data migrations and for changes a migration cannot express safely. Both belong in the repository.
Is AddDbContextPool always better?
No. It helps when contexts are created frequently and hold no per-request state. It can hurt when your model or options are built per request, because pooling caches them.
Related
Controllers, dependency injection and middleware Error handling, health checks and resilience
Last refreshed 2026-09-18.