
Learn Entity Framework Core 9 migrations, relationships, and performance tips with runnable C# examples. Master EF Core 9 best practices today.
Entity Framework Core is the default data-access layer for modern .NET applications, and Entity Framework Core 9 (shipped with .NET 9) is the most capable release yet. Whether you're a beginner learning how to create your first migration, an intermediate developer looking for EF Core best practices, or a senior engineer hunting down slow queries, this Entity Framework Core 9 tutorial covers the three areas that matter most in real projects: migrations, relationships, and performance. Every example is runnable C# you can drop into an ASP.NET Core or console app.
What's New in Entity Framework Core 9?
Before diving in, here's why EF Core 9 is worth upgrading to:
- Improved LINQ translation — more queries translate to SQL instead of throwing "could not be translated" exceptions, including better support for
GroupBywith complex keys andContainson parameters. - Complex types and primitive collections — arrays like
List<int>map to JSON columns and can be queried server-side. - Auto-compiled models — startup time for large models drops significantly with compiled models that stay in sync automatically.
- Azure Cosmos DB provider rewrite — a near-complete overhaul for document databases.
- Migration improvements — better seeding via
UseSeedingandUseAsyncSeeding, plus safer handling of concurrent migrations.
Setting Up an Entity Framework Core 9 Project
Install the packages for your provider. SQL Server is shown here, but PostgreSQL (Npgsql.EntityFrameworkCore.PostgreSQL) and SQLite work identically.
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'll use a blog with authors, posts, and tags — enough to show one-to-many, one-to-one, and many-to-many relationships.
public class Author
{
public int Id { get; set; }
public required string Name { get; set; }
public AuthorProfile? Profile { get; set; } // one-to-one
public List<Post> Posts { get; set; } = []; // one-to-many
}
public class AuthorProfile
{
public int Id { get; set; }
public string? Bio { get; set; }
public int AuthorId { get; set; } // FK to Author
public Author Author { get; set; } = null!;
}
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public string Content { get; set; } = string.Empty;
public DateTime PublishedAt { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public List<Tag> Tags { get; set; } = []; // many-to-many
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Post> Posts { get; set; } = [];
}
Now the DbContext. Registering it with dependency injection in ASP.NET Core is one line in Program.cs.
public class BlogContext(DbContextOptions<BlogContext> options) : DbContext(options)
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Post> Posts => Set<Post>();
public DbSet<Tag> Tags => Set<Tag>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasIndex(p => p.PublishedAt);
modelBuilder.Entity<Tag>()
.HasIndex(t => t.Name)
.IsUnique();
}
}
// Program.cs
builder.Services.AddDbContext<BlogContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Blog")));
EF Core Migrations: Evolving Your Database Schema Safely
Migrations are how Entity Framework Core keeps your database schema in sync with your C# model over time. Instead of hand-writing ALTER TABLE scripts, you describe changes in code and EF Core generates the SQL.
Creating and Applying Your First Migration
dotnet ef migrations add InitialCreate
dotnet ef database update
The first command scaffolds a Migrations/ folder containing an Up() method (apply) and a Down() method (roll back). The second executes it against your database and records it in the __EFMigrationsHistory table.
Why You Should Always Review Generated Migrations
EF Core cannot know your intent. If you rename Post.Content to Post.Body, EF Core sees a dropped column and an added column — your data disappears. Open the migration and replace the drop/add pair with a rename:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Generated (destructive) — replace this:
// migrationBuilder.DropColumn(name: "Content", table: "Posts");
// migrationBuilder.AddColumn<string>(name: "Body", table: "Posts", nullable: false, defaultValue: "");
// Safe rename:
migrationBuilder.RenameColumn(name: "Content", table: "Posts", newName: "Body");
}
Applying Migrations in Production the Right Way
Calling context.Database.Migrate() at startup is convenient for development but risky in production: multiple instances of your app may race to run the same migration, and a failed migration takes your app down. The recommended approach is to generate an idempotent SQL script and run it as a deployment step:
dotnet ef migrations script --idempotent --output migrate.sql
Or use a migration bundle — a self-contained executable that your CI/CD pipeline runs before the new app version goes live:
dotnet ef migrations bundle --self-contained -r linux-x64
./efbundle --connection "Server=prod;Database=Blog;..."
Seeding Data in EF Core 9
EF Core 9 introduces UseSeeding and UseAsyncSeeding, which run after migrations and are far more flexible than the older HasData approach because they can run arbitrary logic:
options.UseSqlServer(connectionString)
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var tags = context.Set<Tag>();
if (!await tags.AnyAsync(cancellationToken))
{
tags.AddRange(new Tag { Name = "csharp" }, new Tag { Name = "dotnet" });
await context.SaveChangesAsync(cancellationToken);
}
});
EF Core Relationships Explained: One-to-Many, One-to-One, Many-to-Many
Relationships are where most beginner confusion lives. EF Core discovers them by convention, but understanding the rules lets you configure them deliberately with the Fluent API.
One-to-Many (Author → Posts)
By convention, a List<Post> on Author plus an AuthorId on Post is enough. Explicit configuration lets you control delete behavior, which is the most important decision here:
modelBuilder.Entity<Author>()
.HasMany(a => a.Posts)
.WithOne(p => p.Author)
.HasForeignKey(p => p.AuthorId)
.OnDelete(DeleteBehavior.Restrict); // don't silently delete posts
Why Restrict? The default for required relationships is Cascade. Deleting an author would wipe every post they wrote. For most business data you want the database to refuse the delete so you handle it explicitly.
One-to-One (Author → AuthorProfile)
One-to-one requires you to tell EF Core which side holds the foreign key, because it can't always infer it:
modelBuilder.Entity<Author>()
.HasOne(a => a.Profile)
.WithOne(p => p.Author)
.HasForeignKey<AuthorProfile>(p => p.AuthorId);
Many-to-Many (Post ↔ Tag)
Since EF Core 5, collections on both sides create an implicit join table (PostTag). If you need extra columns on the join — for example, when a tag was added — define the join entity explicitly:
public class PostTag
{
public int PostId { get; set; }
public int TagId { get; set; }
public DateTime AddedAt { get; set; }
}
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity<PostTag>(
j => j.Property(pt => pt.AddedAt).HasDefaultValueSql("GETUTCDATE()"));
Loading Related Data
EF Core never loads navigation properties automatically. You choose between eager loading (Include), explicit loading, or projection. Projection is almost always the fastest:
// Eager loading — pulls full entities
var authors = await context.Authors
.Include(a => a.Posts.Where(p => p.PublishedAt > DateTime.UtcNow.AddDays(-30)))
.ThenInclude(p => p.Tags)
.ToListAsync();
// Projection — only the columns you need, no tracking overhead
var summaries = await context.Authors
.Select(a => new
{
a.Name,
PostCount = a.Posts.Count,
LatestTitle = a.Posts.OrderByDescending(p => p.PublishedAt)
.Select(p => p.Title).FirstOrDefault()
})
.ToListAsync();
Entity Framework Core Performance Tips That Actually Matter
EF Core is fast when used correctly and painfully slow when misused. These are the optimizations that deliver the largest wins, roughly in order of impact.
1. Use AsNoTracking for Read-Only Queries
Change tracking costs memory and CPU for every entity loaded. If you're not going to modify the data, turn it off:
var posts = await context.Posts
.AsNoTracking()
.Where(p => p.AuthorId == authorId)
.ToListAsync();
// Or make it the default for the whole context:
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
2. Kill the N+1 Problem
The classic mistake: loop over authors and touch author.Posts inside the loop. With lazy loading enabled, that's one query per author. Use Include or projection so the database does the work in a single round trip. You can catch this in development by logging queries:
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging(); // dev only!
3. Split Queries for Multiple Collection Includes
Including two or more collections in one query creates a "cartesian explosion" — the row count multiplies. AsSplitQuery sends one query per collection instead:
var authors = await context.Authors
.Include(a => a.Posts)
.Include(a => a.Profile)
.AsSplitQuery()
.ToListAsync();
4. Use ExecuteUpdate and ExecuteDelete for Bulk Operations
Loading 10,000 rows to change a flag is wasteful. Bulk operations run a single UPDATE statement without loading anything into memory:
await context.Posts
.Where(p => p.PublishedAt < DateTime.UtcNow.AddYears(-5))
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Title, p => "[Archived] " + p.Title));
await context.Posts
.Where(p => p.Content == string.Empty)
.ExecuteDeleteAsync();
5. Compiled Queries for Hot Paths
EF Core caches query plans, but compiling the LINQ expression still costs time on every call. For queries executed thousands of times per second, precompile them:
private static readonly Func<BlogContext, int, Task<Post?>> GetPostById =
EF.CompileAsyncQuery((BlogContext ctx, int id) =>
ctx.Posts.AsNoTracking().FirstOrDefault(p => p.Id == id));
var post = await GetPostById(context, 42);
6. Use DbContext Pooling and Async Everywhere
AddDbContextPool reuses context instances instead of creating a new one per request, cutting allocations in high-throughput APIs. Combine it with async methods (ToListAsync, SaveChangesAsync) so threads aren't blocked waiting on the database:
builder.Services.AddDbContextPool<BlogContext>(options =>
options.UseSqlServer(connectionString), poolSize: 128);
7. Paginate Properly
Offset pagination (Skip/Take) degrades as page numbers grow because the database still scans skipped rows. Keyset pagination stays fast on any page:
var page = await context.Posts
.AsNoTracking()
.Where(p => p.Id > lastSeenId)
.OrderBy(p => p.Id)
.Take(50)
.ToListAsync();
Common Entity Framework Core Pitfalls to Avoid
- Calling
ToList()too early — filtering after materialization pulls the whole table into memory. KeepWherebeforeToListAsync. - Long-lived DbContext instances — a context is a unit of work, not a singleton. Its change tracker grows unbounded and it isn't thread-safe.
- Ignoring
DeleteBehavior— accept the cascade default consciously, never by accident. - Running
Migrate()from multiple app instances — use bundles or SQL scripts in your deployment pipeline instead. - Forgetting indexes on foreign keys and filter columns — EF Core creates FK indexes automatically, but columns you filter or sort on need explicit
HasIndex. - Using lazy loading in web apps — it hides N+1 queries and serializes navigation graphs unexpectedly. Prefer explicit
Includeor projection.
Conclusion: Key Takeaways for Entity Framework Core 9
Entity Framework Core 9 is a mature, production-ready ORM, but getting the most out of it requires intent rather than relying on defaults. Here's what to remember:
- Migrations: always review generated code, prefer renames over drop/add, and deploy with idempotent scripts or bundles rather than
Migrate()at startup. - Relationships: configure delete behavior explicitly, use explicit join entities when you need payload columns, and load related data with
Includeor projection — never rely on lazy loading in web apps. - Performance:
AsNoTracking, projection, split queries, bulkExecuteUpdate/ExecuteDelete, compiled queries, context pooling, and keyset pagination cover 90% of real-world slowdowns.
Apply these Entity Framework Core best practices and your .NET 9 applications will be faster, safer to evolve, and far easier to maintain. For a next step, explore EF Core 9's JSON column mapping and interceptors — both open up powerful patterns that build directly on the fundamentals covered here.
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