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 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 operationsExecuteUpdate and ExecuteDelete (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 HasData only 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.Orders inside the loop fires one query per customer. Use Include or projection.
  • Long-lived DbContext instances. A DbContext is a unit of work, not a singleton. Its change tracker grows unbounded, and it is not thread-safe.
  • Forgetting await on 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. Calling ToList() before Where() 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 with HasIndex.
  • Using DateTime.Now in queries. Use DateTime.UtcNow and 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 AsNoTracking for reads; use AsSplitQuery when including multiple collections.
  • Reach for ExecuteUpdate/ExecuteDelete for 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.

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