LEARN BUILD GROW TOGETHER
DEVELOPER LEARNING HUB
Learn. Build. Grow Together.

Practical tutorials, real-world examples and interview preparation.

.NETC#SQL SERVERANGULAR
Home /.NET 10 /Entity Framework Core (EF Core) – Complete Developer Learning Guide

Entity Framework Core (EF Core) – Complete Developer Learning Guide

ENTITY FRAMEWORK CORE · .NET 10

Entity Framework Core: Complete Developer Guide

A practical reference to modern data access with EF Core: DbContext, entities, LINQ, relationships, migrations, querying, tracking, transactions, concurrency, performance and deployment.

Entity Framework Core (EF Core) is a modern, lightweight, extensible, open-source, and cross-platform Object-Relational Mapper (ORM) for .NET. It allows developers to work with databases using .NET objects and LINQ instead of writing SQL for every data operation.

EF Core maps application classes and properties to database tables and columns. It can generate SQL queries, track changes to entities, and persist those changes to a database through a provider.

Scope: This guide covers Entity Framework Core for modern .NET applications, targeting net10.0. It is separate from classic Entity Framework 6 (EF6) on .NET Framework. EF Core and EF6 have different APIs, providers, migration behavior, and capabilities. Do not copy EF6-only examples such as EDMX designer workflows or System.Data.Entity into an EF Core project.

1. What Is Entity Framework Core?

EF Core is an ORM that provides an object-oriented way to access relational databases. Instead of manually mapping every database row to a C# object, EF Core performs much of that work through entity mapping and database providers.

For example, a database table named Products can be represented by a C# class named Product. EF Core can query the table and materialize the returned rows as Product objects.

var products = await context.Products.ToListAsync();

EF Core translates the LINQ query into database-specific SQL through the configured provider, executes it, and maps the result to .NET objects.

2. EF Core: Modern ORM and Database Operations

Entity Framework Core is more than a way to avoid writing SQL. It is a modern ORM and data-access framework that connects an application's object model with a relational database. It coordinates query translation, entity materialization, change tracking, relationship handling, and persistence through a database provider.

ApplicationC# objects / LINQ EF CoreDbContext · ModelChange TrackerQuery pipeline ProviderSQL translation DatabaseTables · rows · keys Query results become .NET objects; SaveChanges sends tracked changes back to the database.

A simplified request and persistence path. The provider handles database-specific SQL and driver communication.

What database operations does EF Core coordinate?

OperationWhat happensCommon EF Core API
CreateMarks a new entity for insertion.Add, AddAsync, SaveChangesAsync
ReadBuilds a query, translates LINQ, executes SQL and materializes results.Where, ToListAsync, FirstOrDefaultAsync
UpdateTracks modified values and generates an UPDATE command.Change a tracked entity, Update, SaveChangesAsync
DeleteMarks an entity for deletion and generates a DELETE command.Remove, SaveChangesAsync
TransactionGroups multiple database commands into an atomic unit where supported.BeginTransactionAsync, CommitAsync

Example: one unit of work

using Microsoft.EntityFrameworkCore;

// CREATE
var product = new Product
{
    Name = "Mechanical Keyboard",
    Price = 2499m,
    IsActive = true
};

context.Products.Add(product);
await context.SaveChangesAsync();

// READ
var activeProducts = await context.Products
    .Where(p => p.IsActive)
    .OrderBy(p => p.Name)
    .AsNoTracking()
    .ToListAsync();

// UPDATE
var existing = await context.Products
    .FirstOrDefaultAsync(p => p.Id == product.Id);

if (existing is not null)
{
    existing.Price = 2299m;
    await context.SaveChangesAsync();
}

// DELETE
if (existing is not null)
{
    context.Products.Remove(existing);
    await context.SaveChangesAsync();
}
Important distinction: LINQ query operators usually build an expression; a terminal operation such as ToListAsync, FirstOrDefaultAsync, or AnyAsync executes it. For tracked entities, calling SaveChangesAsync persists pending changes.

How EF Core differs from hand-written data access

With ADO.NET, developers commonly create commands, manage parameters, execute readers, and map returned columns to objects themselves. EF Core automates much of that repetitive work while still allowing SQL when needed. It does not remove the need to understand SQL, indexes, transactions, execution plans, or relational design.

3. EF Core Fluent API: Database Configuration and Entity Relationships

The Fluent API is EF Core's code-based way to configure how entity classes map to the database. It is used to define table and column names, keys, required fields, lengths, precision, indexes, relationships, delete behavior, and other model rules.

Configuration is usually written in OnModelCreating or separated into IEntityTypeConfiguration<TEntity> classes. Fluent API is especially useful when mapping rules are complex, need to stay out of entity classes, or must be organized by entity.

Entity classesProduct · CategoryProperties + navigation Fluent APIHasKey · PropertyHasOne · WithManyHasIndex · ToTable EF modelMetadata + rulesKeys · columns · FKs DatabaseSchema mappingTables + constraints EF Core combines conventions, data annotations and Fluent API to build its model.

The model is built from configuration; migrations can use that model to generate schema changes.

3.1 Configure table, columns, keys and indexes

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        entity.ToTable("Products");

        entity.HasKey(p => p.Id);

        entity.Property(p => p.Name)
            .IsRequired()
            .HasMaxLength(120);

        entity.Property(p => p.Price)
            .HasPrecision(18, 2);

        entity.HasIndex(p => p.Name);
    });
}
Fluent API methodPurpose
ToTableMaps an entity to a database table.
HasKeyDefines a primary key, including composite keys.
PropertyStarts configuration for a property.
IsRequiredConfigures a required / non-nullable column.
HasMaxLengthSets a maximum length for supported property types.
HasPrecisionConfigures precision and scale for numeric values.
HasColumnNameMaps a property to a specific column name.
HasIndexDefines a database index.
HasDefaultValueConfigures a database default value.

3.2 Configure one-to-many relationships

One category can contain many products. The foreign key is stored on the dependent entity, Product.

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public ICollection<Product> Products { get; set; }
        = new List<Product>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

// In OnModelCreating
modelBuilder.Entity<Product>()
    .HasOne(p => p.Category)
    .WithMany(c => c.Products)
    .HasForeignKey(p => p.CategoryId)
    .OnDelete(DeleteBehavior.Restrict);

3.3 Configure one-to-one relationships

In a one-to-one relationship, one entity is associated with at most one related entity. The example below makes UserProfile.UserId the foreign key.

modelBuilder.Entity<User>()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.UserId);

3.4 Configure many-to-many relationships

EF Core can create a join table for a many-to-many relationship when both entities expose collection navigation properties. Use an explicit join entity when the relationship itself needs extra data, such as enrollment date or status.

modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity<Dictionary<string, object>>(
        "StudentCourse",
        right => right.HasOne<Course>()
            .WithMany().HasForeignKey("CourseId"),
        left => left.HasOne<Student>()
            .WithMany().HasForeignKey("StudentId"),
        join => join.ToTable("StudentCourses"));

3.5 Move configuration into a separate class

For larger applications, keep each entity's mapping in its own configuration class. This keeps DbContext focused and makes database rules easier to locate and test.

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public sealed class ProductConfiguration
    : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products");
        builder.HasKey(p => p.Id);

        builder.Property(p => p.Name)
            .IsRequired()
            .HasMaxLength(120);

        builder.Property(p => p.Price)
            .HasPrecision(18, 2);

        builder.HasOne(p => p.Category)
            .WithMany(c => c.Products)
            .HasForeignKey(p => p.CategoryId);
    }
}

// In DbContext.OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new ProductConfiguration());
}

Alternatively, discover configuration classes from the context's assembly with modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly).

When should you use Fluent API? Use it for relationships, composite keys, indexes, precision, table and column mapping, delete behavior, and any mapping that should remain separate from your domain/entity classes. Data annotations remain useful for simple validation and straightforward mapping metadata.

3.6 Configuration precedence

EF Core applies conventions first, then data annotations, and finally Fluent API configuration. When the same property or relationship is configured in multiple places, Fluent API configuration takes precedence.

4. EF Core vs Entity Framework 6

Entity Framework CoreEntity Framework 6
Modern ORM designed for modern .NET.Earlier ORM commonly used with .NET Framework.
Namespace: Microsoft.EntityFrameworkCore.Common namespace: System.Data.Entity.
Uses provider packages such as SQL Server provider for EF Core.Uses EF6 provider ecosystem.
Supports modern .NET and cross-platform scenarios, subject to provider support.Primarily associated with .NET Framework applications, with some EF6 versions also supporting .NET.
Uses EF Core migrations and modern tooling.Has EF6 migrations and legacy EDMX workflows.
No EDMX designer workflow equivalent to EF6 Database First.Supports EDMX model designer workflows.
Uses APIs such as AsNoTracking, Include, and EF Core-specific configuration.Similar concepts exist, but APIs and behavior are not always identical.
Important: EF Core is not simply a newer version of EF6. It is a separate implementation with its own feature set and provider ecosystem. Check the EF Core documentation for the exact version when migrating an application.
\n

5. EF Core Architecture and Workflow

EF Core sits between application code and the database provider. A typical query and save workflow looks like this:

  1. The application uses a DbContext to query or modify entities.
  2. EF Core uses the model metadata to understand entity-to-database mappings.
  3. LINQ queries are translated by the provider into database-specific SQL.
  4. The provider communicates with the database using its underlying driver.
  5. Returned rows are materialized into entity objects.
  6. When SaveChanges or SaveChangesAsync is called, EF Core detects tracked changes and generates database commands.

Key building blocks include:

  • Entity: a .NET class mapped to data.
  • DbContext: a unit-of-work and session with the database.
  • DbSet: represents a queryable and change-tracked collection of an entity type.
  • Model: metadata describing entities, keys, properties, relationships, and mappings.
  • Provider: translates EF Core operations for a specific database.

6. Database Providers

EF Core uses database providers to communicate with different database systems. The provider determines how queries and database operations are translated.

DatabaseCommon provider package
Microsoft SQL ServerMicrosoft.EntityFrameworkCore.SqlServer
SQLiteMicrosoft.EntityFrameworkCore.Sqlite
PostgreSQLNpgsql.EntityFrameworkCore.PostgreSQL
MySQLProvider-specific package, such as Pomelo's EF Core provider
In-memory providerMicrosoft.EntityFrameworkCore.InMemory

Use a provider version compatible with the EF Core major version and target framework. The in-memory provider is useful for some tests but is not a relational database substitute; it does not reproduce SQL translation, relational constraints, transactions, or provider-specific behavior.

7. Install Packages and Create an EF Core Project

For a .NET 10 application using SQL Server, install the EF Core SQL Server provider and design-time tooling.

dotnet new console -n EfCoreDemo
cd EfCoreDemo

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design

Use package versions compatible with EF Core 10. For command-line migrations, install the EF Core CLI tool if it is not already available:

dotnet tool install --global dotnet-ef
dotnet ef --version

For Visual Studio Package Manager Console workflows, the Microsoft.EntityFrameworkCore.Tools package is commonly used.

Keep the EF Core runtime provider, Design package, Tools package, and dotnet-ef tool aligned to compatible versions. Do not mix EF Core 8, 9, and 10 packages in one application without a documented compatibility reason.

8. Entities and Conventions

An entity is a class that EF Core includes in its model. By convention, EF Core can discover entity types through DbSet properties and navigation properties.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public bool IsActive { get; set; }
}

By convention, a property named Id or ProductId is commonly recognized as the primary key. Other conventions infer table names, column types, relationships, and generated values. Conventions can be overridden with attributes or the Fluent API.

Entities do not have to inherit from a special EF Core base class. They are ordinary .NET classes, often called POCOs (Plain Old CLR Objects).

9. DbContext and DbSet

What is DbContext?

DbContext is the main class used to interact with the database. It coordinates querying, change tracking, model configuration, and saving changes. A context instance is designed for a short unit of work.

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    public DbSet<Product> Products => Set<Product>();
}

What is DbSet?

DbSet<TEntity> represents a set of entities of a given type. It provides query and change operations such as Add, Update, Remove, and LINQ query composition.

context.Products.Add(product);
await context.SaveChangesAsync();

DbSet is not simply a list in memory. Querying it usually builds an expression that EF Core translates and executes against the database.

10. Configuring the DbContext

In an ASP.NET Core application, register the context with dependency injection. For SQL Server:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("DefaultConnection")));

Example connection-string configuration:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=.;Database=StoreDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

This is a local development example. Production connection details should be supplied through secure configuration or a secret store. Avoid committing real credentials to source control.

For SQL Server, the provider is configured using UseSqlServer. Other providers use their corresponding extension methods, such as UseSqlite or UseNpgsql.

11. Data Annotations and Organizing Model Configuration

EF Core builds a model describing how entity types map to the database. Configuration can be defined through conventions, data annotations, and the Fluent API.

Data annotations

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class Product
{
    public int Id { get; set; }

    [Required]
    [MaxLength(120)]
    public string Name { get; set; } = "";

    [Column(TypeName = "decimal(18,2)")]
    public decimal Price { get; set; }
}

Fluent API

Fluent configuration is generally written in OnModelCreating or in separate IEntityTypeConfiguration<TEntity> classes.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        entity.ToTable("Products");
        entity.HasKey(p => p.Id);

        entity.Property(p => p.Name)
              .IsRequired()
              .HasMaxLength(120);

        entity.Property(p => p.Price)
              .HasPrecision(18, 2);
    });
}

Fluent API configuration takes precedence over conventions and data annotations when the same mapping is configured differently.

12. Primary Keys and Generated Values

A primary key uniquely identifies an entity. EF Core recognizes common key naming conventions, or you can configure keys explicitly.

modelBuilder.Entity<Product>()
    .HasKey(p => p.Id);

Composite keys contain more than one property:

modelBuilder.Entity<OrderItem>()
    .HasKey(x => new { x.OrderId, x.ProductId });

For SQL Server, integer primary keys are commonly configured as identity columns by convention. Other key-generation strategies can be configured according to the database provider.

modelBuilder.Entity<Product>()
    .Property(p => p.Id)
    .ValueGeneratedOnAdd();

EF Core also supports application-generated keys such as GUIDs. Choose a key strategy based on database design, indexing, distribution, and application requirements.

13. Relationships in EF Core

EF Core supports one-to-one, one-to-many, and many-to-many relationships. Relationships are represented through foreign-key properties and navigation properties.

One-to-many relationship

One category can contain many products. Each product belongs to one category.

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public ICollection<Product> Products { get; set; }
        = new List<Product>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

EF Core can discover this relationship by convention. It can also be configured explicitly:

modelBuilder.Entity<Product>()
    .HasOne(p => p.Category)
    .WithMany(c => c.Products)
    .HasForeignKey(p => p.CategoryId);

One-to-one relationship

modelBuilder.Entity<User>()
    .HasOne(u => u.Profile)
    .WithOne(p => p.User)
    .HasForeignKey<UserProfile>(p => p.UserId);

Many-to-many relationship

A student can enroll in many courses, and a course can have many students. EF Core supports many-to-many relationships and can create a join table by convention.

public class Student
{
    public int Id { get; set; }
    public ICollection<Course> Courses { get; set; }
        = new List<Course>();
}

public class Course
{
    public int Id { get; set; }
    public ICollection<Student> Students { get; set; }
        = new List<Student>();
}

Use an explicit join entity when the relationship itself has additional properties, such as enrollment date, grade, or status.

14. Code First and EF Core Migrations

In the Code First approach, developers define entity classes and model configuration in code. EF Core migrations capture changes to the model and generate database schema updates.

Create the first migration

dotnet ef migrations add InitialCreate

Apply migrations to the database

dotnet ef database update

More migration commands

CommandPurpose
dotnet ef migrations add AddProductPriceCreates a migration from model changes.
dotnet ef migrations listLists migrations.
dotnet ef migrations removeRemoves the last migration if it has not been applied in a way that prevents removal.
dotnet ef database updateUpdates the database to the latest migration.
dotnet ef database update PreviousMigrationMoves the database to a specified migration, applying or reverting changes as appropriate.
dotnet ef migrations scriptGenerates a SQL migration script.

In Visual Studio Package Manager Console, equivalent commands include Add-Migration, Update-Database, Remove-Migration, and Script-Migration.

Production caution: Do not casually run schema-changing migrations automatically at application startup in production. Review generated SQL, coordinate schema changes with releases, back up important data, and use a controlled deployment process.

15. Reverse Engineering an Existing Database

When a database already exists, EF Core can scaffold entity classes and a DbContext from the database schema. This is commonly called reverse engineering or database-first scaffolding.

For SQL Server, install the provider and design package, then run:

dotnet ef dbcontext scaffold
"Name=ConnectionStrings:DefaultConnection"
Microsoft.EntityFrameworkCore.SqlServer
--output-dir Models
--context-dir Data
--context AppDbContext

The command is shown across lines for readability; execute it as one command in the terminal. You can restrict scaffolding to selected tables and schemas using the supported command options.

Scaffolded files can be overwritten when scaffolding is repeated. Put custom behavior in separate partial classes or configuration files, and use the --force option only when overwriting generated files is intended.

EF Core Database First note: EF Core reverse engineering generates code from the database. It is not the EF6 EDMX visual designer workflow. Keep generated code separate from custom code so it can be regenerated safely.

16. CRUD Operations

CRUD stands for Create, Read, Update, and Delete. EF Core exposes these operations through DbSet and DbContext.

Create

var product = new Product
{
    Name = "Keyboard",
    Price = 1200
};

context.Products.Add(product);
await context.SaveChangesAsync();

Read

var products = await context.Products.ToListAsync();

var product = await context.Products
    .FirstOrDefaultAsync(p => p.Id == 10);

Update

var product = await context.Products
    .FirstOrDefaultAsync(p => p.Id == 10);

if (product != null)
{
    product.Price = 1350;
    await context.SaveChangesAsync();
}

Delete

var product = await context.Products.FindAsync(10);

if (product != null)
{
    context.Products.Remove(product);
    await context.SaveChangesAsync();
}

For a tracked entity, modifying its properties and calling SaveChangesAsync is normally sufficient. Calling Update is not required for entities already tracked by the same context.

17. LINQ Queries in EF Core

LINQ provides a strongly typed way to query data. EF Core translates supported LINQ expressions into SQL for the configured provider.

Where

var activeProducts = await context.Products
    .Where(p => p.IsActive)
    .ToListAsync();

OrderBy

var products = await context.Products
    .OrderBy(p => p.Name)
    .ToListAsync();

FirstOrDefault and SingleOrDefault

var product = await context.Products
    .FirstOrDefaultAsync(p => p.Id == id);

FirstOrDefault returns the first matching result or the default value. SingleOrDefault expects at most one match and throws if multiple rows match.

Count, Any and Sum

var count = await context.Products.CountAsync();
var hasProducts = await context.Products.AnyAsync();
var total = await context.Products.SumAsync(p => p.Price);

Pagination

var pageNumber = 2;
var pageSize = 20;

var products = await context.Products
    .OrderBy(p => p.Id)
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Use a deterministic ordering for pagination. For very large datasets or frequently changing data, keyset pagination may be more efficient and stable than large offset values.

18. Change Tracking and EntityState

EF Core tracks changes to entities retrieved through a tracking query. When SaveChanges is called, EF Core uses the tracked state to determine which database commands are needed.

EntityStateMeaning
DetachedEntity is not tracked by the context.
UnchangedEntity is tracked and has no detected changes.
AddedEntity will be inserted.
ModifiedEntity has one or more modified properties.
DeletedEntity will be deleted.

Inspect an entity's state:

var product = await context.Products.FindAsync(10);

if (product != null)
{
    product.Price = 1500;

    var state = context.Entry(product).State;
    await context.SaveChangesAsync();
}

AsNoTracking

For read-only queries where changes will not be saved through the same context, use AsNoTracking to avoid change-tracking overhead.

var products = await context.Products
    .AsNoTracking()
    .ToListAsync();

Do not use no-tracking queries when you expect to modify the returned entities and rely on automatic tracking.

19. Eager, Explicit and Lazy Loading

Loading strategies determine when related entities are retrieved.

Eager loading with Include

Eager loading retrieves related data as part of the query.

var products = await context.Products
    .Include(p => p.Category)
    .ToListAsync();

ThenInclude

Use ThenInclude to include a further related navigation property.

var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .ToListAsync();

Explicit loading

Explicit loading retrieves a navigation property after the entity has already been loaded.

var category = await context.Categories.FindAsync(1);

if (category != null)
{
    await context.Entry(category)
        .Collection(c => c.Products)
        .LoadAsync();
}

Lazy loading

Lazy loading retrieves related data when a navigation property is accessed. EF Core supports lazy-loading proxies when the appropriate package is installed and navigation properties are configured for proxy use.

dotnet add package Microsoft.EntityFrameworkCore.Proxies
options.UseLazyLoadingProxies()
       .UseSqlServer(connectionString);

Lazy loading can cause unexpected database round trips and the N+1 query problem. Use it deliberately, especially in web applications where serialization or view rendering can access navigation properties repeatedly.

20. Projection and DTOs

Projection selects only the columns needed by the application instead of loading complete entity graphs. It is often a good approach for list pages and read-only screens.

var productRows = await context.Products
    .Where(p => p.IsActive)
    .Select(p => new ProductListItem
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price
    })
    .ToListAsync();

Projection can reduce transferred data and memory usage. It also helps keep database entities separate from the application's external or UI-facing data contracts.

21. Raw SQL and Stored Procedures

EF Core supports raw SQL when LINQ cannot express a required query or when an existing database procedure must be used. Prefer parameterized APIs and never concatenate untrusted input into SQL strings.

Query entities using SQL

var products = await context.Products
    .FromSqlInterpolated(
        $"SELECT * FROM Products WHERE IsActive = {true}")
    .ToListAsync();

Execute a non-query SQL command

var affected = await context.Database
    .ExecuteSqlInterpolatedAsync(
        $"UPDATE Products SET IsActive = {false} WHERE Id = {id}");

For stored procedures, supported syntax and composability depend on the database provider. For SQL Server, a procedure returning entity-shaped results can be called using supported raw SQL query APIs; a procedure that performs a command can be invoked using an execute SQL API. Check the provider documentation for limitations.

SQL safety: Never build SQL by concatenating user-provided strings. Use parameterized SQL APIs. Raw SQL bypasses some of the abstraction and should be reviewed for correctness and security.

22. Transactions and Savepoints

EF Core automatically wraps a single SaveChanges call in a transaction when the database provider supports transactions. If an operation fails, the transaction can be rolled back.

For multiple operations that must succeed or fail together, create an explicit transaction:

await using var transaction =
    await context.Database.BeginTransactionAsync();

try
{
    context.Products.Add(product);
    await context.SaveChangesAsync();

    context.AuditLogs.Add(auditLog);
    await context.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

EF Core can create savepoints within an existing transaction when supported by the provider. Provider capabilities and database settings can affect transaction behavior.

23. Optimistic Concurrency

Optimistic concurrency detects when data has changed in the database since it was read. EF Core can use a concurrency token to detect conflicting updates.

For SQL Server, a rowversion property can be used:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

When a concurrency conflict occurs during SaveChanges, EF Core throws DbUpdateConcurrencyException. The application should decide whether to reload data, retry, merge changes, or show a conflict to the user.

Concurrency-token support and column types are provider-specific. SQL Server rowversion is not a universal SQL type across all database engines.

24. Global Query Filters

Global query filters apply a condition automatically whenever an entity type is queried. They are often used for soft deletion or tenant filtering.

modelBuilder.Entity<Product>()
    .HasQueryFilter(p => !p.IsDeleted);

Queries against Products will exclude soft-deleted records by default. EF Core provides IgnoreQueryFilters when a query intentionally needs to bypass filters.

Security note: A tenant query filter must be designed carefully and must not be treated as the only authorization boundary. Ensure tenant identity is established securely and cannot be changed by untrusted request input.

25. Interceptors and Logging

EF Core interceptors can observe or modify certain operations, including database commands, connection operations, and SaveChanges events. They are useful for cross-cutting behavior such as auditing or diagnostics.

EF Core also integrates with .NET logging. Configure logging through application services and use it to inspect generated SQL during development.

options
    .UseSqlServer(connectionString)
    .LogTo(Console.WriteLine);

For sensitive-data logging, use EnableSensitiveDataLogging only in controlled development environments. It can expose parameter values and sensitive information in logs.

26. EF Core Performance and Query Optimization

  • Project only the columns required by the screen or operation.
  • Use AsNoTracking for read-only entity queries where appropriate.
  • Avoid loading large related graphs unnecessarily.
  • Watch for N+1 queries when accessing navigation properties.
  • Use pagination for large result sets.
  • Inspect generated SQL and execution plans.
  • Create suitable database indexes based on query patterns.
  • Use asynchronous APIs for I/O-bound database operations.
  • Reuse a context for a short unit of work; do not keep it alive indefinitely.
  • Consider compiled queries for measured hot paths with repeated query shapes.

Split queries

Including multiple collection navigations can create large joined result sets. EF Core supports split queries using AsSplitQuery to retrieve related collections through separate SQL queries in supported scenarios.

var blogs = await context.Blogs
    .Include(b => b.Posts)
    .AsSplitQuery()
    .ToListAsync();

Split queries can reduce duplicated data in joins but may involve multiple round trips and consistency considerations. Measure the actual workload.

27. Asynchronous Database Operations

EF Core provides asynchronous methods for I/O-bound operations. Async calls allow the application thread to be released while waiting for database work to complete.

SynchronousAsynchronous
ToList()ToListAsync()
FirstOrDefault()FirstOrDefaultAsync()
SingleOrDefault()SingleOrDefaultAsync()
Any()AnyAsync()
Count()CountAsync()
SaveChanges()SaveChangesAsync()
public async Task<List<Product>> GetProductsAsync()
{
    return await context.Products
        .AsNoTracking()
        .ToListAsync();
}

Do not run multiple parallel operations on the same DbContext instance. DbContext is not thread-safe. Use separate context instances for independent concurrent operations.

28. Dependency Injection and DbContext Lifetime

In ASP.NET Core, AddDbContext registers the context as scoped by default. This normally means one context instance per web request scope.

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

Inject the context into an application service or repository through its constructor:

public class ProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }
}

A scoped context is suitable for many web request workflows. Avoid storing a DbContext in a singleton or sharing it across threads. For background services, create a scope or use IDbContextFactory<TContext> when appropriate.

29. Security Considerations

  • Use parameterized LINQ and raw SQL APIs.
  • Never concatenate untrusted input into SQL commands.
  • Keep connection strings and credentials out of source control.
  • Use least-privilege database accounts.
  • Validate input and enforce authorization in the application.
  • Do not expose internal database errors to end users.
  • Protect sensitive data in logs and diagnostic output.
  • Review tenant filters and soft-delete behavior for authorization gaps.
  • Keep EF Core and provider packages updated with compatible security fixes.

30. Testing EF Core Applications

Test application logic separately from database-provider behavior. EF Core offers several ways to test, but no substitute behaves exactly like every production relational database.

Testing approachUseImportant limitation
Unit tests with mocked abstractionsTest business logic and service behavior.Does not test actual EF query translation.
SQLite in-memoryRelational integration-style tests with a lightweight database.Behavior and SQL dialect differ from SQL Server or another production provider.
Production-provider integration testsValidate queries, constraints, transactions, and provider behavior.Requires managing a test database or container.
EF Core InMemory providerSimple tests of some application flows.Not relational; does not reproduce relational constraints or SQL translation.

For reliable database behavior tests, prefer testing against the same database provider used in production, often with an isolated test database.

31. Production Migrations and Deployment

Database schema changes should be part of a controlled release process. EF Core can generate SQL scripts from migrations, allowing teams to review and apply schema changes through deployment pipelines.

dotnet ef migrations script

For an idempotent script that checks which migrations have already been applied:

dotnet ef migrations script --idempotent

Use reviewed scripts or migration bundles where appropriate for the deployment process. Test migrations against a production-like database and plan for backup, rollback, and compatibility between application versions and schema changes.

Deployment caution: Schema changes can be difficult to reverse, especially after data has been modified. Prefer backward-compatible, staged changes when deploying applications with multiple instances or rolling releases.

32. EF Core Best Practices

  • Use a short-lived DbContext for one unit of work.
  • Use dependency injection to manage context lifetime.
  • Keep database entities separate from UI ViewModels and external DTOs.
  • Use Fluent API for complex or centralized model configuration.
  • Use migrations with source control and review schema changes.
  • Use asynchronous APIs for database I/O in web applications.
  • Use no-tracking queries for read-only workloads when appropriate.
  • Project only the data required by the operation.
  • Inspect SQL and execution plans for important queries.
  • Use transactions for operations that must be atomic.
  • Handle concurrency conflicts deliberately.
  • Test against the production database provider for provider-specific behavior.
  • Never expose secrets or sensitive SQL parameter values in logs.
  • Keep provider and EF Core package versions compatible.

33. Official Microsoft Documentation

Version boundary: This guide is for EF Core with modern .NET 10. Classic Entity Framework 6 topics such as EDMX designer models, System.Data.Entity, and EF6-specific migration commands belong in the separate EF6 guide.

What’s Next?

The next complete tutorial in this collection is ASP.NET Core Web API with .NET 10 – Complete Developer Guide. It will cover REST API architecture, controllers, routing, HTTP methods, model binding, validation, responses, authentication, authorization, versioning, testing, and deployment using the modern ASP.NET Core Web API stack.