Entity Framework Core (EF Core) is Microsoft's open-source object-relational mapper for .NET. You describe your data as C# classes, query it with LINQ, and EF Core turns that into SQL for SQL Server, PostgreSQL, SQLite, Azure Cosmos DB and many other databases. This guide is for developers who want a solid, production-minded foundation: it covers DbContext, modeling, relationships, migrations, querying, change tracking and saving, then moves on to complex types, JSON columns, named query filters and what changed in EF Core 9, EF Core 10 and the upcoming EF Core 11.
What Is Entity Framework Core?#
An object-relational mapper (ORM) bridges object graphs in memory and rows in relational tables. EF Core generates SQL from LINQ, materializes rows into objects, tracks the changes you make and writes them back in a transaction. It also manages schema evolution through migrations.
EF Core is a ground-up rewrite of Entity Framework 6. EF6 still exists for .NET Framework applications, but new work happens in EF Core, which is cross-platform, provider-based and considerably faster. EF Core versions track .NET: EF Core 10 ships with .NET 10, the current LTS release, and requires the .NET 10 runtime.
EF Core is a good default for business applications and APIs where most data access is CRUD over a domain model. For hand-tuned reporting queries or extreme hot paths, many teams pair it with a micro-ORM, as described in Dapper and ADO.NET: High-Performance Data Access.
How Entity Framework Core Works#
Five moving parts explain almost every behavior you will see:
- The model. On first use of a context type, EF Core builds a metadata model from your classes by applying conventions, data annotations and Fluent API configuration, in increasing order of precedence. The model is cached for the process lifetime.
- The
DbContext. A context is a unit of work: it owns a connection (opened on demand), a change tracker and theDbSet<TEntity>properties you query through. Contexts are cheap, short-lived and not thread-safe. - The query pipeline. A LINQ query builds an expression tree that runs only when you enumerate or await it. EF Core translates it to SQL, caches the translation, sends parameters separately and materializes results.
- The change tracker. Tracked entities keep a snapshot of their original values.
SaveChangescompares snapshots, computes the minimalINSERT,UPDATEandDELETEstatements, batches them and runs them in a transaction. - The provider. A provider package supplies the SQL dialect, type mappings, function translations and migration SQL. Swapping providers changes the SQL, not your model code.
Because queries are expression trees, the deferred-execution rules from LINQ in Depth apply. One EF-specific rule matters early: only the final projection may run on the client. If a Where or OrderBy cannot be translated to SQL, EF Core throws instead of silently downloading the table.
Getting Started with EF Core#
A minimal setup needs a provider package, the design-time package for tooling and the dotnet-ef CLI tool. The following creates an ASP.NET Core app that uses SQL Server:
dotnet new web -n Shop.Api
cd Shop.Api
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef
# After adding the model and context below:
dotnet ef migrations add InitialCreate
dotnet ef database updateNext, define an entity and a context. Exposing sets as Set<T>() expression-bodied properties keeps the compiler's nullable analysis happy without null! initializers:
using Microsoft.EntityFrameworkCore;
namespace Shop.Api.Data;
public sealed class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDbContext).Assembly);
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
=> configurationBuilder.Properties<decimal>().HavePrecision(18, 2);
}
public class Product
{
public int Id { get; set; } // "Id" becomes the primary key by convention
public required string Name { get; set; } // non-nullable reference type => NOT NULL
public string? Description { get; set; } // nullable => NULL allowed
public decimal Price { get; set; }
}Finally, register the context with dependency injection and use it from endpoints. AddDbContext registers the context with a scoped lifetime, so each HTTP request gets its own instance:
using Microsoft.EntityFrameworkCore;
using Shop.Api.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
var app = builder.Build();
app.MapGet("/products", async (ShopDbContext db, CancellationToken ct) =>
await db.Products
.OrderBy(p => p.Name)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync(ct));
app.MapPost("/products", async (CreateProduct input, ShopDbContext db, CancellationToken ct) =>
{
var product = new Product { Name = input.Name, Price = input.Price };
db.Products.Add(product);
await db.SaveChangesAsync(ct); // INSERT runs here; the generated Id is written back
return Results.Created($"/products/{product.Id}", product.Id);
});
app.Run();
public sealed record ProductDto(int Id, string Name, decimal Price);
public sealed record CreateProduct(string Name, decimal Price);The GET endpoint projects straight into a DTO, so EF Core selects three columns and tracks nothing. Shaping data in the query rather than after it is the most valuable EF Core habit.
Modeling: Conventions, Data Annotations and the Fluent API#
EF Core discovers most of your schema by convention. A property named Id or <TypeName>Id becomes the key, integer keys become identity columns, non-nullable reference types become NOT NULL, and CustomerId next to a Customer navigation becomes a foreign key. You override conventions in three ways:
- Data annotations such as
[MaxLength],[Index]and[Timestamp]are concise but mix persistence concerns into domain types. - The Fluent API in
OnModelCreatingorIEntityTypeConfiguration<T>classes wins over annotations and can express everything. - Pre-convention configuration in
ConfigureConventionssets defaults per CLR type, such as a precision for everydecimal. It counts as explicit configuration and overrides data annotations.
Grouping configuration per entity keeps OnModelCreating small, and ApplyConfigurationsFromAssembly picks the classes up automatically:
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Shop.Api.Data;
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }
[Index(nameof(Number), IsUnique = true)]
public class Order
{
public int Id { get; set; }
[MaxLength(32)]
public required string Number { get; set; }
public OrderStatus Status { get; set; }
public DateTimeOffset PlacedAt { get; set; }
public DateTimeOffset? ShippedAt { get; set; }
public int CustomerId { get; set; } // foreign key by convention
public Customer Customer { get; set; } = null!;
public List<OrderLine> Lines { get; } = [];
}
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders", schema: "sales");
builder.Property(o => o.Status)
.HasConversion<string>() // store "Paid" instead of 1
.HasMaxLength(20);
builder.Property(o => o.PlacedAt)
.HasDefaultValueSql("SYSDATETIMEOFFSET()");
}
}Storing enums as strings costs a few bytes per row but keeps data readable and safe against reordered enum members. Be explicit about string lengths and decimal precision too; the SQL Server defaults are rarely what you want.
Relationships: One-to-Many, One-to-One and Many-to-Many#
Relationships are defined by navigations and foreign keys. The nullability of the foreign key decides whether a relationship is required or optional. Required relationships cascade deletes by default; optional ones set the foreign key to null for tracked dependents.
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public CustomerProfile? Profile { get; set; } // one-to-one (optional)
public List<Order> Orders { get; } = []; // one-to-many
}
public class CustomerProfile
{
public int Id { get; set; }
public int CustomerId { get; set; } // unique FK => one-to-one
public string? LoyaltyTier { get; set; }
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Product> Products { get; } = []; // Product also gets a List<Tag> Tags
}
// In OnModelCreating or an IEntityTypeConfiguration<T>:
modelBuilder.Entity<Customer>()
.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict); // never cascade-delete order history
modelBuilder.Entity<Customer>()
.HasOne(c => c.Profile)
.WithOne()
.HasForeignKey<CustomerProfile>(p => p.CustomerId);
modelBuilder.Entity<Product>()
.HasMany(p => p.Tags)
.WithMany(t => t.Products)
.UsingEntity("ProductTags"); // names the hidden join tableMany-to-many relationships use skip navigations: EF Core manages the join entity, so adding a Tag to product.Tags inserts a join row. If the join carries data, such as when a tag was applied, model it as a real entity. Be deliberate about deletes: cascading suits true aggregates, such as lines inside an order, but is dangerous across aggregate boundaries, and SQL Server rejects multiple cascade paths to one table.
Migrations: Evolving the Schema#
Migrations capture model changes as C# classes with Up and Down methods plus a model snapshot. Change the model, scaffold a migration, review it, then apply it.
dotnet ef migrations add AddOrders # scaffold from model changes, then review the code
dotnet ef migrations list # applied and pending migrations
dotnet ef migrations remove # delete the last migration if it is not applied
dotnet ef migrations script --idempotent -o artifacts/migrate.sql
dotnet ef migrations bundle --self-contained -r linux-x64 -o artifacts/efbundleEF Core 9 made the workflow safer: it throws when the model has changes no migration captures, locks the database so two processes cannot migrate at once, and adds UseSeeding and UseAsyncSeeding for seed data. EF Core 11 records the latest migration ID in the model snapshot, so divergent migrations on two branches surface as a merge conflict.
In production, generate an idempotent script or a bundle in CI and apply it as a deployment step rather than calling Migrate() at startup. The full workflow, including expand-contract changes, is covered in Database Migrations and Zero-Downtime Schema Changes.
Change Tracking Basics#
Every tracked entity has a state: Added, Unchanged, Modified, Deleted or Detached. Tracking queries attach results as Unchanged with a snapshot of original values, and SaveChanges updates only the columns that changed. Tracking queries also perform identity resolution: the same row always maps to the same object instance.
AsNoTracking() skips that work for read-only queries and is noticeably cheaper for large results. AsNoTrackingWithIdentityResolution() avoids tracking but still de-duplicates instances. For disconnected scenarios, such as an API receiving a DTO, load the entity, apply the incoming values and save; Update() on a detached graph marks every property as modified.
var product = await db.Products.SingleAsync(p => p.Id == id, ct); // tracked, Unchanged
product.Price = 24.99m;
Console.WriteLine(db.Entry(product).State); // Modified
Console.WriteLine(db.ChangeTracker.DebugView.LongView); // every tracked entry
try
{
await db.SaveChangesAsync(ct); // UPDATE [Products] SET [Price] = @p0 ... in a transaction
}
catch (DbUpdateConcurrencyException ex)
{
// Requires a concurrency token, e.g. [Timestamp] public byte[] Version { get; set; }
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync(ct);
// Decide: reload and retry, merge values, or return HTTP 409 to the caller.
}Saving Data: SaveChanges, Transactions and Bulk Updates#
SaveChanges wraps all pending changes in one transaction on relational providers and batches statements into few round trips. With a concurrency token, such as a rowversion mapped through [Timestamp], EF Core adds the original token to the WHERE clause and throws DbUpdateConcurrencyException when no row matches: optimistic concurrency for free.
For set-based changes, loading entities just to modify them is wasteful. ExecuteUpdate and ExecuteDelete (EF Core 7 and later) translate straight to UPDATE and DELETE:
// Set-based UPDATE: no entities loaded, no change tracking
var repriced = await db.Products
.Where(p => p.Tags.Any(t => t.Name == "clearance"))
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 0.8m), ct);
// EF Core 10: setters can be built with ordinary statements
await db.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(s =>
{
s.SetProperty(o => o.Status, newStatus);
if (newStatus == OrderStatus.Shipped)
{
s.SetProperty(o => o.ShippedAt, DateTimeOffset.UtcNow);
}
}, ct);
// Set-based DELETE
await db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.PlacedAt < cutoff)
.ExecuteDeleteAsync(ct);These methods run immediately, bypass the change tracker and start no transaction of their own. Wrap several in Database.BeginTransactionAsync() when they must succeed together, and do not mix them with tracked edits to the same rows, because tracked entities will hold stale values.
Complex Types and JSON Columns#
Value objects such as addresses or money have no identity of their own. EF Core 8 introduced complex types for them, and EF Core 10 made them the recommended approach, adding optional values, structs, collections and JSON mapping. Unlike owned entity types, complex types have value semantics: you can assign one address to two properties, compare addresses by content in LINQ, and update them with ExecuteUpdate.
public sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed record ShipmentEvent(DateTimeOffset At, string Status, string? Note);
// Customer gains: public required Address BillingAddress { get; set; }
// public Address? ShippingAddress { get; set; } (optional, EF Core 10)
// Order gains: public List<ShipmentEvent> Tracking { get; set; } = [];
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(c => c.BillingAddress); // columns in Customers
b.ComplexProperty(c => c.ShippingAddress, a => a.ToJson()); // one JSON column
});
modelBuilder.Entity<Order>()
.ComplexCollection(o => o.Tracking, t => t.ToJson()); // JSON array column
var berliners = await db.Customers
.Where(c => c.BillingAddress.City == "Berlin")
.ToListAsync(ct);Table splitting, the default, maps each member to a column such as BillingAddress_City; ToJson() stores the whole value in one column and allows nested collections. On Azure SQL, or SQL Server 2025 at compatibility level 170, EF Core 10 uses the native json type automatically, and existing nvarchar JSON columns are altered to json by the next migration unless you pin the column type. EF Core 10 also maps the SQL Server vector type through SqlVector<float> and EF.Functions.VectorDistance.
Global Query Filters and Named Filters in EF Core 10#
Global query filters add a predicate to every query for an entity type, which suits soft deletion and multi-tenancy. Before EF Core 10 each entity had one filter, and IgnoreQueryFilters() removed all of it. EF Core 10 adds named filters that you can disable selectively:
public sealed class BillingDbContext(
DbContextOptions<BillingDbContext> options, ITenantContext tenant) : DbContext(options)
{
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<Invoice>()
.HasQueryFilter("SoftDelete", i => !i.IsDeleted)
.HasQueryFilter("Tenant", i => i.TenantId == tenant.TenantId);
}
// Admin view: show deleted invoices, but keep tenant isolation in place
var invoices = await db.Invoices
.IgnoreQueryFilters(["SoftDelete"])
.ToListAsync(ct);Two cautions apply. A filtered principal behind a required navigation can make Include drop dependents through an inner join, so filter both sides consistently or make the navigation optional. Filters can also be defined only on the root type of an inheritance hierarchy.
What's New in EF Core 9 and EF Core 10#
| Release | Released | Runs on | Support | Supported until |
|---|---|---|---|---|
| EF Core 8 | November 2023 | .NET 8 | LTS | November 10, 2026 |
| EF Core 9 | November 2024 | .NET 8 and .NET 9 | STS | November 10, 2026 |
| EF Core 10 | November 2025 | .NET 10 only | LTS | November 10, 2028 |
| EF Core 11 | Planned for November 2026 | .NET 11 only | STS | Aligned with .NET 11 |
EF Core 9 highlights:
- A largely rewritten Azure Cosmos DB provider with hierarchical partition keys and pagination.
- Migration safety: a lock against concurrent migration, an exception for pending model changes, and warnings for operations that cannot run in a transaction.
UseSeedingandUseAsyncSeeding, auto-discovered compiled models and MSBuild model compilation throughMicrosoft.EntityFrameworkCore.Tasks.- Experimental Native AOT with precompiled queries,
EF.Parameterand leaner SQL.
EF Core 10 highlights:
- Complex types with optional values, structs, collections and JSON mapping, now preferred over owned types.
- Native
jsonandvectorsupport for Azure SQL and SQL Server 2025. - Named query filters,
LeftJoin/RightJoin, consistent split-query ordering and multi-parameter collection translation. ExecuteUpdateover JSON properties and with statement lambdas.- Inlined constants redacted from SQL logs, and an analyzer that flags string concatenation in raw SQL APIs.
- Azure Cosmos DB full-text and hybrid search, with vector search out of preview.
EF Core 11 reached release candidate 1 on September 8, 2026 with a go-live license. It adds complex types for TPT/TPC hierarchies and Cosmos DB, FullJoin, MaxBy/MinBy, SQL Server vector indexes with VECTOR_SEARCH(), and ExcludeForeignKeyFromMigrations(), and it makes UseSqlServer default to compatibility level 160 (SQL Server 2022). Read the breaking-changes page before every major upgrade.
Choosing a Database Provider#
Providers are separate NuGet packages that generally do not work across EF Core major versions, so confirm your provider has a release for your EF version.
| Package | Database | Maintainer | Notes |
|---|---|---|---|
Microsoft.EntityFrameworkCore.SqlServer | SQL Server, Azure SQL | Microsoft | Richest feature set, including JSON, vector and temporal tables |
Npgsql.EntityFrameworkCore.PostgreSQL | PostgreSQL | Npgsql team | Version 10 supports EF Core 10; see PostgreSQL with .NET |
Microsoft.EntityFrameworkCore.Sqlite | SQLite | Microsoft | Embedded apps, tools and realistic test databases |
Microsoft.EntityFrameworkCore.Cosmos | Azure Cosmos DB for NoSQL | Microsoft | Document modeling, partition keys, vector and full-text search |
Pomelo.EntityFrameworkCore.MySql | MySQL, MariaDB | Pomelo Foundation | Community provider; releases can trail new EF versions |
Oracle.EntityFrameworkCore | Oracle Database | Oracle | Vendor-maintained |
MongoDB.EntityFrameworkCore | MongoDB | MongoDB | Vendor-maintained, document-oriented subset |
Microsoft.EntityFrameworkCore.InMemory | None (in-process) | Microsoft | Built for EF's own tests; a poor stand-in for a real database |
Best Practices#
- Keep contexts short-lived. One context per request or unit of work. Use
IDbContextFactory<TContext>for Blazor Server, background services and parallel work; never share a context across threads. - Shape reads in the query. Project to DTOs, page with a stable
OrderBy, and useAsNoTracking()for read-only entities. - Go async end to end and pass a
CancellationTokenso abandoned requests stop hitting the database. - Configure explicitly. Set string lengths, precision, unique indexes and delete behavior; conventions are not a schema design.
- Treat migrations as code. Review them, commit them and apply them from your pipeline.
- Watch the SQL with
LogToorILoggerin development, and keepEnableSensitiveDataLoggingout of production. - Model value objects as complex types on EF Core 10 and later.
- Test against the real engine, for example in a container. The EF team explicitly discourages the in-memory provider as a database fake.
Common Pitfalls#
- N+1 queries. Lazy loading or queries inside loops turn one page view into hundreds of round trips.
- Cartesian explosion. Several collection
Includecalls multiply rows; useAsSplitQuery()or projection. - Materializing too early.
ToList()orAsEnumerable()beforeWheredownloads the table and filters in memory. - Long-lived contexts accumulate tracked entities, grow memory and serve stale data.
- Concurrent use of one context throws; await every call before starting the next.
- Blind
Update()on detached graphs can overwrite concurrent changes. - Mixing
ExecuteUpdatewith tracked entities lets a laterSaveChangesoverwrite bulk changes. - Annotations silently overridden by pre-convention configuration, which beats
[MaxLength].
EF Core vs Dapper vs ADO.NET#
| Aspect | EF Core | Dapper | Raw ADO.NET |
|---|---|---|---|
| Abstraction | Full ORM with LINQ, tracking and migrations | Micro-ORM: you write SQL, it maps results | Connections, commands and data readers |
| Who writes SQL | EF Core, with raw SQL available | You | You |
| Change tracking and unit of work | Built in | None | None |
| Schema management | Migrations included | External tools such as DbUp | External tools |
| Runtime overhead | Low to moderate, tunable | Very low | Lowest |
| Best fit | Domain-driven CRUD and most business apps | Read-heavy queries and reporting | Hot paths, bulk loads, provider-specific features |
Mature codebases often use EF Core for the write model and everyday queries, and Dapper or ADO.NET for the few queries where hand-written SQL clearly wins. Both can share one connection and transaction.
Frequently Asked Questions#
Is Entity Framework Core fast enough for high-traffic applications?#
Yes, for the large majority of workloads, provided you write queries deliberately. Projection, AsNoTracking, context pooling and compiled queries remove most of the overhead, and the remaining cost is usually small next to database and network time. Profile first, and move only proven hot paths to Dapper or raw SQL.
Which EF Core version should I use in 2026?#
Use EF Core 10 on .NET 10 for new and actively maintained applications, because it is the current LTS release and is supported until November 2028. EF Core 8 and EF Core 9 both reach end of support on November 10, 2026, so plan upgrades now. Adopt EF Core 11 when your application moves to .NET 11.
Do I still need the repository pattern with EF Core?#
Usually not as a generic wrapper. DbContext is already a unit of work, and DbSet<TEntity> already behaves like a repository. A thin, query-specific abstraction still helps when you want to unit test business logic without a database or to hide persistence details from a domain layer.
Should I use complex types or owned entity types?#
On EF Core 10 and later, prefer complex types for value objects. They have value semantics, support structs, optional values and JSON mapping, and work with ExecuteUpdate, which owned types do not. Keep owned types only where you need a capability complex types still lack, such as mapping to a separate table.
Can EF Core work with an existing database?#
Yes. dotnet ef dbcontext scaffold reverse-engineers entity classes and a context from an existing schema, after which you can keep scaffolding as the database changes or switch to code-first migrations. Keyless entity types and raw SQL through FromSql and SqlQuery cover views and stored procedures.
Summary#
- EF Core maps classes to tables, translates LINQ to SQL, tracks changes and saves them transactionally.
- A short-lived
DbContext, explicit configuration and query-side projection are the foundations of a healthy data layer. - Choose eager, explicit or projection-based loading deliberately; avoid lazy loading in server code.
- EF Core 10 (LTS) brings complex types with JSON support, named query filters, native
jsonandvectortypes, and safer logging. - EF Core 11 is at RC1 and ships with .NET 11 in November 2026.