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:

  1. 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.
  2. The DbContext. A context is a unit of work: it owns a connection (opened on demand), a change tracker and the DbSet<TEntity> properties you query through. Contexts are cheap, short-lived and not thread-safe.
  3. 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.
  4. The change tracker. Tracked entities keep a snapshot of their original values. SaveChanges compares snapshots, computes the minimal INSERT, UPDATE and DELETE statements, batches them and runs them in a transaction.
  5. 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:

Bash
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 update

Next, define an entity and a context. Exposing sets as Set<T>() expression-bodied properties keeps the compiler's nullable analysis happy without null! initializers:

C#
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:

C#
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 OnModelCreating or IEntityTypeConfiguration<T> classes wins over annotations and can express everything.
  • Pre-convention configuration in ConfigureConventions sets defaults per CLR type, such as a precision for every decimal. It counts as explicit configuration and overrides data annotations.

Grouping configuration per entity keeps OnModelCreating small, and ApplyConfigurationsFromAssembly picks the classes up automatically:

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

C#
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 table

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

Bash
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/efbundle

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

Querying Data with LINQ#

Good EF Core queries filter and page in the database, select only the columns the caller needs, and are asynchronous and cancellable.

C#
public sealed record OrderSummary(int Id, string Number, string Customer, decimal Total, int Lines);

public static async Task<List<OrderSummary>> GetRecentOrdersAsync(
    ShopDbContext db, int page, int pageSize, CancellationToken ct)
{
    var since = DateTimeOffset.UtcNow.AddDays(-30);

    return await db.Orders
        .Where(o => o.PlacedAt >= since && o.Status != OrderStatus.Cancelled)
        .OrderByDescending(o => o.PlacedAt)
        .ThenBy(o => o.Id)                                  // stable order for paging
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o => new OrderSummary(
            o.Id,
            o.Number,
            o.Customer.Name,                                // becomes a JOIN, no Include needed
            o.Lines.Sum(l => l.Quantity * l.UnitPrice),     // aggregated in SQL
            o.Lines.Count))
        .ToListAsync(ct);
}

// .NET 10 and EF Core 10: first-class LEFT JOIN without GroupJoin/DefaultIfEmpty
var tiers = await db.Customers
    .LeftJoin(
        db.Set<CustomerProfile>(),
        c => c.Id,
        p => p.CustomerId,
        (c, p) => new { c.Name, Tier = p == null ? null : p.LoyaltyTier })
    .ToListAsync(ct);

Variables such as since and pageSize become SQL parameters, so the database reuses one cached plan, while literal constants are inlined. For local collections, as in ids.Contains(o.Id), EF Core 10 sends one parameter per value and pads the list to limit plan-cache churn, where EF Core 8 and 9 sent a single JSON array. EF.Constant and EF.Parameter override the choice per query.

Queried entities do not bring their navigations along automatically. You choose how related data is loaded:

StrategyHowRound tripsUse it when
EagerInclude / ThenIncludeOne (or one per collection with split queries)You know up front that you need the related data
ExplicitEntry(x).Collection(...).LoadAsync()One per loadYou decide at runtime whether to load
LazyProxies or ILazyLoader, on first accessOne per navigation touchedRarely in server code; it hides N+1 queries
ProjectionSelect into a DTOOneRead paths that do not need entities at all
C#
// Eager loading with a filtered include and split queries
var order = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines.Where(l => l.Quantity > 0))
        .ThenInclude(l => l.Product)
    .AsSplitQuery()                                  // avoids a cartesian product
    .SingleAsync(o => o.Id == orderId, ct);

// Explicit loading: fetch a navigation later, or query it without loading it all
var customer = await db.Customers.SingleAsync(c => c.Id == customerId, ct);
await db.Entry(customer).Collection(c => c.Orders).LoadAsync(ct);

var pendingCount = await db.Entry(customer)
    .Collection(c => c.Orders)
    .Query()
    .CountAsync(o => o.Status == OrderStatus.Pending, ct);

Lazy loading needs the Microsoft.EntityFrameworkCore.Proxies package, UseLazyLoadingProxies() and virtual navigations. In web APIs it tends to issue one query per row, the classic N+1 problem covered in EF Core Performance Tuning.

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.

C#
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:

C#
// 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.

C#
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:

C#
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#

ReleaseReleasedRuns onSupportSupported until
EF Core 8November 2023.NET 8LTSNovember 10, 2026
EF Core 9November 2024.NET 8 and .NET 9STSNovember 10, 2026
EF Core 10November 2025.NET 10 onlyLTSNovember 10, 2028
EF Core 11Planned for November 2026.NET 11 onlySTSAligned 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.
  • UseSeeding and UseAsyncSeeding, auto-discovered compiled models and MSBuild model compilation through Microsoft.EntityFrameworkCore.Tasks.
  • Experimental Native AOT with precompiled queries, EF.Parameter and leaner SQL.

EF Core 10 highlights:

  • Complex types with optional values, structs, collections and JSON mapping, now preferred over owned types.
  • Native json and vector support for Azure SQL and SQL Server 2025.
  • Named query filters, LeftJoin/RightJoin, consistent split-query ordering and multi-parameter collection translation.
  • ExecuteUpdate over 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.

PackageDatabaseMaintainerNotes
Microsoft.EntityFrameworkCore.SqlServerSQL Server, Azure SQLMicrosoftRichest feature set, including JSON, vector and temporal tables
Npgsql.EntityFrameworkCore.PostgreSQLPostgreSQLNpgsql teamVersion 10 supports EF Core 10; see PostgreSQL with .NET
Microsoft.EntityFrameworkCore.SqliteSQLiteMicrosoftEmbedded apps, tools and realistic test databases
Microsoft.EntityFrameworkCore.CosmosAzure Cosmos DB for NoSQLMicrosoftDocument modeling, partition keys, vector and full-text search
Pomelo.EntityFrameworkCore.MySqlMySQL, MariaDBPomelo FoundationCommunity provider; releases can trail new EF versions
Oracle.EntityFrameworkCoreOracle DatabaseOracleVendor-maintained
MongoDB.EntityFrameworkCoreMongoDBMongoDBVendor-maintained, document-oriented subset
Microsoft.EntityFrameworkCore.InMemoryNone (in-process)MicrosoftBuilt 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 use AsNoTracking() for read-only entities.
  • Go async end to end and pass a CancellationToken so 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 LogTo or ILogger in development, and keep EnableSensitiveDataLogging out 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 Include calls multiply rows; use AsSplitQuery() or projection.
  • Materializing too early. ToList() or AsEnumerable() before Where downloads 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 ExecuteUpdate with tracked entities lets a later SaveChanges overwrite bulk changes.
  • Annotations silently overridden by pre-convention configuration, which beats [MaxLength].

EF Core vs Dapper vs ADO.NET#

AspectEF CoreDapperRaw ADO.NET
AbstractionFull ORM with LINQ, tracking and migrationsMicro-ORM: you write SQL, it maps resultsConnections, commands and data readers
Who writes SQLEF Core, with raw SQL availableYouYou
Change tracking and unit of workBuilt inNoneNone
Schema managementMigrations includedExternal tools such as DbUpExternal tools
Runtime overheadLow to moderate, tunableVery lowLowest
Best fitDomain-driven CRUD and most business appsRead-heavy queries and reportingHot 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 json and vector types, and safer logging.
  • EF Core 11 is at RC1 and ships with .NET 11 in November 2026.

Further Reading#