Entity Framework
Entity Framework Core
Entity Framework Core (EF Core) is Microsoft's official ORM for .NET. It maps C# classes to database tables, translates LINQ queries to SQL, tracks changes to loaded entities, and manages schema migrations. EF Core supports SQL Server, PostgreSQL, SQLite, MySQL, and Cosmos DB through provider packages.
EF Core is the default data access layer for most .NET applications. Understanding its change tracking, query translation, and migration system is essential for building correct and performant data layers.
Core Concepts
DbContext and DbSet
DbContext is the unit of work and the entry point for all database operations. DbSet<T> represents a table and is the starting point for queries.
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(b =>
{
b.HasKey(o => o.Id);
b.Property(o => o.Total).HasPrecision(18, 2);
b.HasMany(o => o.LineItems).WithOne().HasForeignKey(li => li.OrderId);
});
}
}
Change Tracking
EF Core tracks all entities loaded through a DbContext. When you call SaveChangesAsync(), it generates INSERT/UPDATE/DELETE SQL for all tracked changes.
// Load → modify → save: EF Core detects the change automatically
var order = await db.Orders.FindAsync(orderId);
order.Status = OrderStatus.Confirmed; // EF Core marks this as Modified
await db.SaveChangesAsync(); // generates: UPDATE Orders SET Status = 'Confirmed' WHERE Id = @id
For read-only queries where you don't need change tracking, use .AsNoTracking() to reduce memory and CPU overhead:
var orders = await db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync();
Migrations
EF Core migrations track schema changes as C# code, enabling version-controlled, repeatable schema evolution.
# Create a migration after changing the model
dotnet ef migrations add AddOrderStatus
# Apply pending migrations to the database
dotnet ef database update
Generated migration:
public partial class AddOrderStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Status",
table: "Orders",
nullable: false,
defaultValue: "Draft");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Status", table: "Orders");
}
}
Performance Patterns
Projection Instead of Full Entity Load
Loading full entities when you only need a few columns wastes bandwidth and memory. Project to a DTO:
// BAD: loads all columns including large blobs
var orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync();
// GOOD: project to only needed columns
var summaries = await db.Orders
.Where(o => o.CustomerId == id)
.Select(o => new OrderSummary(o.Id, o.Total, o.Status))
.ToListAsync();
Avoiding N+1 Queries
Loading a list of orders and then accessing order.Customer for each one triggers N additional queries.
// BAD: N+1 — one query for orders, one per order for customer
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
Console.WriteLine(order.Customer.Name); // lazy load per order
// GOOD: eager load with Include
var orders = await db.Orders
.Include(o => o.Customer)
.ToListAsync();
Pitfalls
Lazy Loading in Production
What goes wrong: lazy loading is enabled and navigation properties are accessed in loops, causing N+1 queries. A page that loads 100 orders and accesses order.Customer for each fires 101 queries.
Why it happens: lazy loading is convenient in development but hides query patterns.
Mitigation: disable lazy loading in production (it's off by default in EF Core). Use explicit Include() for eager loading or split queries for large result sets.
Code First vs Database First
- Code First: C# model is the source of truth. Migrations generate and evolve the schema. Best for new projects with a strong domain model.
- Database First: scaffold the model from an existing schema with
dotnet ef dbcontext scaffold. Best for legacy databases or DBA-controlled schemas.
Decision rule: use Code First for new projects. Use Database First when integrating with an existing database you don't own.
Zero-Downtime Migrations
Adding a non-nullable column without a default value locks the table during migration. For large tables, this causes downtime.
Mitigation: use the expand-contract pattern:
- Add the column as nullable (no lock).
- Backfill existing rows in batches.
- Add a NOT NULL constraint after backfill completes.
- Remove the old column in a later migration.
Questions
- EF Core takes a snapshot of each loaded entity's property values. On
SaveChangesAsync(), it compares current values to the snapshot and generates SQL for changed properties. - Overhead: change tracking adds memory (snapshot storage) and CPU (comparison on save) per tracked entity.
- Disable with
.AsNoTracking()for read-only queries (reports, API responses that don't modify data). This is the single most impactful EF Core performance optimization for read-heavy workloads. - Tradeoff:
.AsNoTracking()entities cannot be modified and saved — you must re-attach them or useExecuteUpdateAsync()for bulk updates.
- N+1 occurs when loading N entities and then accessing a navigation property on each, triggering N additional queries.
- Detection: enable EF Core query logging (
LogTo(Console.WriteLine)) or use MiniProfiler/Application Insights to see query counts per request. - Fix: use
Include()for eager loading, or split into two queries and join in memory for large result sets. - Tradeoff:
Include()generates a JOIN, which can produce a large result set if the included collection is large. For collections with >100 items per parent, considerAsSplitQuery()to use separate queries instead of a JOIN.
References
- EF Core documentation (Microsoft Learn) — official reference covering DbContext, migrations, querying, change tracking, and all supported database providers.
- EF Core performance (Microsoft Learn) — official performance guide: AsNoTracking, projections, compiled queries, bulk operations, and connection pooling.
- EF Core migrations (Microsoft Learn) — complete migrations guide: creating, applying, reverting, and customizing migrations for production deployments.
- Using lazy loading in EF Core 8 — practitioner post on EF Core 8 lazy loading configuration, pitfalls, and when to use it vs eager loading.
Parent
03 Data Persistence