Skip to main content

Entity Framework Core 9 Tutorial: Migrations & Performance

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 script to 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 IQueryable for building queries, IEnumerable only 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 AddDbContextPool instead of AddDbContext for 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 migrations to 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 use ExecuteUpdate/ExecuteDelete for 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.

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...