
Learn Entity Framework Core 9 with migrations, relationships, and performance tips. Practical C# examples and best practices for EF Core developers.
Entity Framework Core 9 Tutorial — Migrations, Relationships & Performance Tips
Entity Framework Core is the most popular ORM for .NET developers, and version 9 brings significant improvements to migrations, query performance, and relationship mapping. Whether you're building your first API or optimizing a production application, mastering EF Core is essential for modern C# development. In this Entity Framework Core tutorial, you'll learn how to handle code first migrations, configure complex EF Core relationships, and apply proven EF Core performance techniques — all with practical, runnable code examples.
Setting Up Entity Framework Core 9 in Your Project
Before diving into migrations and relationships, let's set up a clean EF Core 9 project. You'll need the .NET 9 SDK installed.
// Install the required NuGet packages:
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.*
// dotnet add package Microsoft.EntityFrameworkCore.Tools --version 9.*
// dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.*
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
public DbSet<Tag> Tags => Set<Tag>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlServer("Server=.;Database=BookStore;Trusted_Connection=true;TrustServerCertificate=true;");
}
}
For ASP.NET Core applications, register the context in Program.cs instead:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
EF Core Migrations — The Complete Guide
EF Core migrations let you evolve your database schema alongside your C# models without losing data. This "code first" approach means your entity classes drive the database structure, and EF Core generates the SQL to apply changes.
Creating Your First Migration
Define your entity classes first:
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Book> Books { get; set; } = new List<Book>();
}
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public decimal Price { get; set; }
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Book> Books { get; set; } = new List<Book>();
}
Now run the following commands from your project directory:
// Create the initial migration
// dotnet ef migrations add InitialCreate
// Apply the migration to the database
// dotnet ef database update
EF Core generates a migration file with Up() and Down() methods. The Up() method applies the change; Down() rolls it back.
Modifying Your Schema with Subsequent Migrations
When you need to add a column, change a type, or create a new table, just modify your entity class and create a new migration:
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? Bio { get; set; } // New nullable column
public string? WebsiteUrl { get; set; } // New nullable column
public ICollection<Book> Books { get; set; } = new List<Book>();
}
// Then run:
// dotnet ef migrations add AddAuthorBioAndWebsite
// dotnet ef database update
Best practice: Always make new columns nullable or provide a default value. Adding a non-nullable column to a table with existing data will fail unless you supply a default.
Code First Migrations — Common Pitfalls
- Never edit migration files after they've been applied to a shared database. Create a new migration instead.
- Always review generated SQL before applying to production — use
dotnet ef migrations scriptto generate a SQL script for review. - Handle data migrations carefully — if you need to transform data during a schema change, use raw SQL inside the migration's
Up()method. - Keep your DbContext and models in a separate class library if your solution has multiple projects.
// Generate an idempotent SQL script for production deployments
// dotnet ef migrations script --idempotent -o migrate.sql
// Roll back to a specific migration
// dotnet ef database update InitialCreate
// Remove the last unapplied migration
// dotnet ef migrations remove
EF Core Relationships — One-to-Many, Many-to-Many & One-to-One
Configuring EF Core relationships correctly is one of the most important skills when working with Entity Framework Core. EF Core 9 supports convention-based mapping, but explicit configuration with the Fluent API gives you full control.
One-to-Many Relationship
The Author-to-Books relationship above is a classic one-to-many. EF Core detects this by convention, but here's how to configure it explicitly:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>(entity =>
{
entity.HasOne(b => b.Author)
.WithMany(a => a.Books)
.HasForeignKey(b => b.AuthorId)
.OnDelete(DeleteBehavior.Cascade);
entity.Property(b => b.Price)
.HasPrecision(10, 2);
entity.HasIndex(b => b.Title);
});
}
Many-to-Many Relationship
EF Core 9 handles many-to-many relationships without requiring a join entity. The Book-to-Tag relationship is configured automatically by convention. However, if you need to add extra columns to the join table, define an explicit join entity:
public class BookTag
{
public int BookId { get; set; }
public int TagId { get; set; }
public DateTime TaggedAt { get; set; } = DateTime.UtcNow;
public Book Book { get; set; } = null!;
public Tag Tag { get; set; } = null!;
}
// Fluent API configuration
modelBuilder.Entity<BookTag>(entity =>
{
entity.HasKey(bt => new { bt.BookId, bt.TagId });
entity.HasOne(bt => bt.Book)
.WithMany()
.HasForeignKey(bt => bt.BookId);
entity.HasOne(bt => bt.Tag)
.WithMany()
.HasForeignKey(bt => bt.TagId);
});
One-to-One Relationship
public class AuthorProfile
{
public int Id { get; set; }
public string TwitterHandle { get; set; } = string.Empty;
public string GitHubUrl { get; set; } = string.Empty;
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;
}
// Configuration
modelBuilder.Entity<Author>()
.HasOne<AuthorProfile>()
.WithOne(p => p.Author)
.HasForeignKey<AuthorProfile>(p => p.AuthorId);
EF Core Performance Tips That Actually Matter
Poor EF Core performance is rarely the framework's fault — it's almost always caused by how you write queries. Here are the techniques that make the biggest difference in production.
1. Use AsNoTracking for Read-Only Queries
By default, EF Core tracks every entity it loads so it can detect changes. If you're only reading data (for an API response, a report, or a display page), skip the tracking overhead:
// Slow — tracks all returned entities
var books = await context.Books
.Include(b => b.Author)
.ToListAsync();
// Fast — no change tracking overhead
var books = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.ToListAsync();
// Even faster in EF Core 9 — no identity resolution either
var books = await context.Books
.AsNoTrackingWithIdentityResolution()
.Include(b => b.Author)
.ToListAsync();
For APIs where most endpoints are read-heavy, configure no-tracking as the default:
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
2. Avoid the N+1 Query Problem
This is the most common performance killer. Loading related entities in a loop generates one SQL query per iteration:
// BAD — N+1 queries: 1 for authors + N for each author's books
var authors = await context.Authors.ToListAsync();
foreach (var author in authors)
{
var books = await context.Books
.Where(b => b.AuthorId == author.Id)
.ToListAsync();
}
// GOOD — 1 query with a JOIN
var authors = await context.Authors
.Include(a => a.Books)
.ToListAsync();
// BEST for large datasets — use a projection
var authorDtos = await context.Authors
.Select(a => new AuthorDto
{
Name = a.Name,
BookCount = a.Books.Count,
LatestBook = a.Books
.OrderByDescending(b => b.Id)
.Select(b => b.Title)
.FirstOrDefault()
})
.ToListAsync();
3. Use Compiled Queries for Hot Paths
EF Core 9 improved compiled queries significantly. For queries that run thousands of times per minute, compile them once:
private static readonly Func<AppDbContext, int, Task<Book?>> GetBookById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Books
.AsNoTracking()
.Include(b => b.Author)
.FirstOrDefault(b => b.Id == id));
// Usage — skips query compilation on every call
var book = await GetBookById(context, 42);
4. Use Split Queries for Multiple Includes
When you load an entity with multiple collection navigations, EF Core generates a cartesian explosion by default. Split queries execute separate SQL statements instead:
var authors = await context.Authors
.Include(a => a.Books)
.Include(a => a.Courses)
.AsSplitQuery()
.ToListAsync();
5. Batch Operations with ExecuteUpdate and ExecuteDelete
EF Core 9 supports bulk operations that bypass change tracking entirely. Use them when updating or deleting many rows:
// Update all books by a specific author — single SQL UPDATE statement
await context.Books
.Where(b => b.AuthorId == authorId)
.ExecuteUpdateAsync(s => s
.SetProperty(b => b.Price, b => b.Price * 1.10m));
// Delete all books with zero sales — single SQL DELETE statement
await context.Books
.Where(b => b.SalesCount == 0)
.ExecuteDeleteAsync();
These methods are dramatically faster than loading entities, modifying them, and calling SaveChangesAsync() because they skip materialization and change tracking.
EF Core Best Practices — What Experienced Developers Do
- Use projections (
Select) instead of full entity loads — only fetch the columns you need. This reduces memory usage and speeds up queries. - Add indexes to columns you filter or sort by — use
HasIndex()in the Fluent API or[Index]attributes on your entities. - Use
IQueryablefor building queries,IEnumerableonly after materialization — calling.ToList()too early pulls all rows into memory before filtering. - Set a global query filter for soft deletes instead of adding
.Where(x => !x.IsDeleted)to every query. - Log your SQL queries during development — add
options.LogTo(Console.WriteLine)to your DbContext configuration and review the generated SQL. - Pool your DbContext — use
AddDbContextPoolinstead ofAddDbContextfor high-throughput applications.
// Global query filter for soft deletes
modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted);
// DbContext pooling for better throughput
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString), poolSize: 128);
New in EF Core 9 — Features Worth Knowing
- Pre-compiled queries — EF Core 9 introduced ahead-of-time (AOT) compiled queries for better startup performance in NativeAOT scenarios.
- Improved complex type support — value objects without identity can be mapped more naturally.
- Enhanced LINQ translation — more C# expressions are now translated to SQL instead of throwing client evaluation exceptions.
- HierarchyId support for SQL Server — hierarchical data structures are now first-class citizens.
- Sentinel values for better default handling — EF Core 9 distinguishes between "not set" and "set to default value" more reliably.
Conclusion — Mastering Entity Framework Core
Entity Framework Core 9 is a mature, high-performance ORM that handles everything from simple CRUD to complex domain models. The key takeaways from this tutorial:
- Migrations — use
code first migrationsto keep your database schema in sync with your models. Always generate SQL scripts for production deployments. - Relationships — configure relationships explicitly with the Fluent API for full control over foreign keys, cascade behavior, and indexes.
- Performance — use
AsNoTracking(), avoid N+1 queries, prefer projections over full entity loads, and useExecuteUpdate/ExecuteDeletefor bulk operations.
Start applying these EF Core best practices in your next project, and you'll see measurable improvements in both query speed and code maintainability.
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