
Learn Entity Framework Core 9 migrations, relationships, and performance tips with runnable C# examples. Master EF Core best practices today.
Entity Framework Core is the default ORM for modern .NET, and EF Core 9 (shipped with .NET 9 and fully supported on .NET 10) is the most polished release yet. Whether you are searching for an Entity Framework Core tutorial to get started, or you are a senior engineer hunting down N+1 queries in production, this guide covers the three things every EF Core developer must master: migrations, relationships, and performance. Every example is runnable C# you can paste into a console or ASP.NET Core project.
Why Entity Framework Core 9 Matters
EF Core 9 is not a rewrite; it is a refinement. The headline improvements are:
- Better LINQ translation — fewer client-side evaluation surprises, smarter handling of
GroupBy, and improved translation of collection parameters. - Migrations hardening — EF Core 9 warns when migrations are applied while pending model changes exist, and locks the migration history table to prevent two app instances migrating at once.
- Bulk operations —
ExecuteUpdateandExecuteDelete(introduced in EF Core 7) are more flexible, letting you set multiple properties with complex expressions. - Compiled models & AOT groundwork — faster startup for large models, important for serverless and containerised workloads.
Understanding why these matter helps you write code that stays fast as your database grows.
Setting Up an Entity Framework Core 9 Project
Create a project and add the SQL Server provider plus the design package (required for migrations tooling):
dotnet new webapi -n ShopApi
cd ShopApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.*
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.*
dotnet tool install --global dotnet-ef
Define a small domain model. We will use it for every example in this article:
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Email { get; set; }
// One-to-many: a customer has many orders
public List<Order> Orders { get; set; } = new();
// One-to-one: a customer has one optional profile
public CustomerProfile? Profile { get; set; }
}
public class CustomerProfile
{
public int Id { get; set; }
public string? Bio { get; set; }
public int CustomerId { get; set; } // FK + unique index = one-to-one
public Customer Customer { get; set; } = null!;
}
public class Order
{
public int Id { get; set; }
public DateTime PlacedAt { get; set; }
public decimal Total { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
// Many-to-many: orders contain many products, products appear in many orders
public List<Product> Products { get; set; } = new();
}
public class Product
{
public int Id { get; set; }
public required string Sku { get; set; }
public decimal Price { get; set; }
public List<Order> Orders { get; set; } = new();
}
Now the DbContext. Prefer the Fluent API over data annotations for anything non-trivial — it keeps persistence concerns out of your domain classes:
using Microsoft.EntityFrameworkCore;
public class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Customer>(e =>
{
e.Property(c => c.Name).HasMaxLength(200);
e.HasIndex(c => c.Email).IsUnique();
// One-to-many
e.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
// One-to-one
e.HasOne(c => c.Profile)
.WithOne(p => p.Customer)
.HasForeignKey<CustomerProfile>(p => p.CustomerId);
});
b.Entity<Order>(e =>
{
e.Property(o => o.Total).HasPrecision(18, 2);
e.HasIndex(o => new { o.CustomerId, o.PlacedAt });
// Many-to-many with an explicit join table name
e.HasMany(o => o.Products)
.WithMany(p => p.Orders)
.UsingEntity("OrderProducts");
});
b.Entity<Product>(e =>
{
e.Property(p => p.Sku).HasMaxLength(50);
e.HasIndex(p => p.Sku).IsUnique();
e.Property(p => p.Price).HasPrecision(18, 2);
});
}
}
Register it in Program.cs:
builder.Services.AddDbContext<ShopDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Shop"))
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment()));
EF Core Migrations: The Right Way
Migrations are how Entity Framework Core evolves your schema without dropping data. The tooling is simple; the discipline around it is what separates smooth deployments from 2 a.m. outages.
Creating and applying migrations
dotnet ef migrations add InitialCreate
dotnet ef database update
EF Core generates a migration class with Up() and Down() methods plus a model snapshot. Always read the generated migration before committing. Renaming a property, for example, usually produces a DropColumn + AddColumn pair — which silently destroys data. Fix it by hand:
public partial class RenameCustomerName : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Preserve data instead of drop + add
migrationBuilder.RenameColumn(
name: "Name",
table: "Customers",
newName: "FullName");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "FullName",
table: "Customers",
newName: "Name");
}
}
Deploying migrations to production
Calling context.Database.Migrate() at application startup is fine for a single-instance dev app, but risky in production: multiple replicas start simultaneously, and a failed migration takes the whole app down. EF Core 9 adds migration locking to mitigate the race, but the recommended approach is still to generate an idempotent SQL script and run it in your CI/CD pipeline before the new code deploys:
dotnet ef migrations script --idempotent --output migrate.sql
Alternatively, a migration bundle is a self-contained executable that needs no SDK on the target machine:
dotnet ef migrations bundle --self-contained -r linux-x64
./efbundle --connection "Server=prod;Database=Shop;..."
Migration best practices
- One migration per logical change. Small migrations are easier to review and roll back.
- Never edit an applied migration. Add a new one instead; otherwise the snapshot and the database diverge.
- Make schema changes backward compatible during zero-downtime deploys: add the new column, deploy code that writes both, backfill, then drop the old column in a later release.
- Use
HasDataonly for static reference data (countries, statuses). Seed transactional data via a separate script.
Mastering EF Core Relationships
Relationships are where most Entity Framework Core bugs live, so it pays to understand how EF Core discovers them.
One-to-many
EF Core detects a one-to-many relationship by convention when it sees a collection navigation on one side and a reference navigation plus a <Principal>Id property on the other. The Fluent configuration above makes the cascade behaviour explicit — always do this, because the default cascade behaviour differs between required and optional relationships and between providers.
One-to-one
The trick is that EF Core needs to know which entity is the dependent. HasForeignKey<CustomerProfile> tells it, and EF Core automatically creates a unique index on CustomerProfile.CustomerId to enforce the one-to-one constraint at the database level.
Many-to-many
Since EF Core 5 you no longer need an explicit join entity. EF Core creates a shadow join table (here named OrderProducts). If you need extra columns on the join — quantity, unit price at time of order — define the join entity explicitly:
public class OrderLine
{
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
public int ProductId { get; set; }
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
// In OnModelCreating:
b.Entity<Order>()
.HasMany(o => o.Products)
.WithMany(p => p.Orders)
.UsingEntity<OrderLine>(
l => l.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId),
l => l.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId),
l => l.HasKey(x => new { x.OrderId, x.ProductId }));
Loading related data
EF Core supports three loading strategies. Choosing the wrong one is the number-one cause of slow EF Core apps.
// Eager loading — one round trip (or a few with AsSplitQuery)
var customers = await db.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Products)
.Where(c => c.Email != null)
.ToListAsync();
// Projection — usually the best choice for read-only endpoints
var summaries = await db.Customers
.Select(c => new CustomerSummaryDto(
c.Id,
c.Name,
c.Orders.Count,
c.Orders.Sum(o => o.Total)))
.ToListAsync();
// Explicit loading — load on demand for a single tracked entity
var customer = await db.Customers.FirstAsync(c => c.Id == 42);
await db.Entry(customer).Collection(c => c.Orders).LoadAsync();
Avoid lazy loading proxies in web applications. They make every navigation property access a hidden database query, which is how N+1 problems creep in unnoticed.
EF Core Performance Tips That Actually Move the Needle
Most EF Core performance problems are not EF Core's fault — they are the result of treating the ORM like an in-memory collection. Here are the optimisations with the biggest payoff, in the order you should apply them.
1. Use AsNoTracking for read-only queries
Change tracking costs memory and CPU for every entity materialised. If you are not going to call SaveChanges, turn it off:
var products = await db.Products
.AsNoTracking()
.Where(p => p.Price < 50)
.ToListAsync();
// Or set it globally for a read-heavy context:
opt.UseSqlServer(cs).UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
2. Project to DTOs instead of loading full entities
The projection example above sends only the columns you need across the wire and skips materialising entire object graphs. On wide tables this alone can cut query time by 5–10×.
3. Fix cartesian explosion with split queries
Including two collections in one query joins them together, multiplying row counts. If a customer has 100 orders and each order has 10 products, a single-query Include returns 1,000 rows per customer. Use split queries:
var data = await db.Customers
.Include(c => c.Orders)
.Include(c => c.Profile)
.AsSplitQuery()
.ToListAsync();
4. Use ExecuteUpdate and ExecuteDelete for bulk changes
Loading 50,000 rows, changing a property, and calling SaveChanges issues 50,000 UPDATE statements. EF Core 9 executes one:
// Apply a 10% price increase to an entire category in a single SQL statement
await db.Products
.Where(p => p.Sku.StartsWith("BOOK-"))
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Price, p => p.Price * 1.10m));
// Delete stale orders without loading them
await db.Orders
.Where(o => o.PlacedAt < DateTime.UtcNow.AddYears(-7))
.ExecuteDeleteAsync();
Note these bypass the change tracker, so any tracked entities in the same context will be stale afterwards.
5. Batch inserts and tune the batch size
EF Core already batches multiple inserts into one round trip. For very large imports, raise the batch size and disable auto-detect changes:
opt.UseSqlServer(cs, sql => sql.MaxBatchSize(500));
db.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var chunk in products.Chunk(5_000))
{
db.Products.AddRange(chunk);
await db.SaveChangesAsync();
db.ChangeTracker.Clear(); // free memory between chunks
}
6. Use compiled queries on hot paths
For a query executed thousands of times per second, skipping expression-tree compilation is measurable:
private static readonly Func<ShopDbContext, int, Task<Customer?>> GetCustomerById =
EF.CompileAsyncQuery((ShopDbContext db, int id) =>
db.Customers.AsNoTracking().FirstOrDefault(c => c.Id == id));
var customer = await GetCustomerById(db, 42);
7. Pool your DbContext
AddDbContextPool reuses context instances instead of constructing one per request, reducing allocations under high load:
builder.Services.AddDbContextPool<ShopDbContext>(opt => opt.UseSqlServer(cs));
Only use pooling if your context has no per-request state in its constructor (for example, injected tenant IDs).
8. Log and measure
You cannot optimise what you cannot see. Enable query logging in development and watch for queries with warnings about client evaluation or missing indexes:
opt.UseSqlServer(cs)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableDetailedErrors();
In production, pair EF Core with OpenTelemetry — the Microsoft.EntityFrameworkCore activity source emits spans for every command.
Common Entity Framework Core Pitfalls
- N+1 queries from lazy loading or loops. Iterating customers and touching
customer.Ordersinside the loop fires one query per customer. UseIncludeor projection. - Long-lived DbContext instances. A
DbContextis a unit of work, not a singleton. Its change tracker grows unbounded, and it is not thread-safe. - Forgetting
awaiton async calls or mixing sync and async on the same context — this causes the notorious "A second operation was started on this context" exception. ToList()too early. CallingToList()beforeWhere()pulls the whole table into memory, then filters in C#.- Missing indexes on foreign keys and filter columns. EF Core creates FK indexes automatically, but not for columns you filter on in
Where. Add them withHasIndex. - Using
DateTime.Nowin queries. UseDateTime.UtcNowand store UTC; timezone bugs across US, UK, and Australian users are painful to unpick later.
Conclusion: Key Takeaways for Entity Framework Core 9
Entity Framework Core 9 makes it easy to be productive and, with a little discipline, easy to be fast. Remember these points:
- Review every generated migration; deploy with idempotent scripts or bundles rather than
Migrate()at startup. - Configure relationships explicitly with the Fluent API, including delete behaviour.
- Prefer projection and
AsNoTrackingfor reads; useAsSplitQuerywhen including multiple collections. - Reach for
ExecuteUpdate/ExecuteDeletefor bulk changes, and compiled queries plus context pooling on hot paths. - Log your SQL. The fastest way to find an EF Core performance problem is to read the query it generates.
Apply these Entity Framework Core best practices and your .NET application will scale from a handful of rows to millions without a rewrite. If you found this tutorial useful, explore our other ASP.NET Core and .NET guides on csharp-coder.com.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment