Skip to main content

Entity Framework Core 9 Tutorial: Migrations & Performance

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 GroupBy with complex keys and Contains on 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 UseSeeding and UseAsyncSeeding, 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. Keep Where before ToListAsync.
  • 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 Include or 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 Include or projection — never rely on lazy loading in web apps.
  • Performance: AsNoTracking, projection, split queries, bulk ExecuteUpdate/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.

About 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

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...