Entity Framework Core in .NET: The Complete Guide
Working with databases is something virtually every .NET developer deals with. Entity framework core is Microsoft's official object-relational mapper (ORM) for .NET, and it has become the default tool for data access in modern C# applications. Whether you're building a web API, a background service, or a full-stack Blazor application, EF Core is likely somewhere in your stack -- and for good reason.
This guide covers everything you need to understand EF Core in .NET 10. We'll walk through the foundational concepts like DbContext and change tracking, how migrations manage your schema, how LINQ queries translate to SQL, and how EF Core fits into larger architectural patterns. Think of this article as your map for navigating the EF Core landscape before going deep on any individual topic.
What Is Entity Framework Core?
Entity Framework Core is a cross-platform, open-source ORM built by Microsoft. It lets you interact with relational databases using C# classes and LINQ queries instead of writing raw SQL strings by hand. EF Core handles the translation of your C# code into the correct SQL dialect for your target database provider.
EF Core supports a wide range of databases via provider packages:
- SQL Server via
Microsoft.EntityFrameworkCore.SqlServer - SQLite via
Microsoft.EntityFrameworkCore.Sqlite - PostgreSQL via
Npgsql.EntityFrameworkCore.PostgreSQL - MySQL / MariaDB via
Pomelo.EntityFrameworkCore.MySql - In-memory via
Microsoft.EntityFrameworkCore.InMemory(great for testing)
The in-memory provider is especially useful for unit tests -- it means you can exercise your data access logic without a real database running locally or in CI. In EF Core 10, provider coverage and SQL translation quality have continued to improve, with better support for complex expressions and reduced need to fall back to client-side evaluation.
Core Concepts You Need to Know
Before writing any code, there are a handful of concepts that are absolutely fundamental to understanding how EF Core works. Get these right and everything else clicks into place.
DbContext -- The Heart of EF Core
DbContext is the central class in EF Core. It serves two distinct primary roles:
- Unit of Work -- it tracks all the changes you make to entities during a request or operation, then commits them together in a single transaction when you call
SaveChangesAsync() - Connection manager -- it manages the database connection lifecycle, including opening, reusing, and closing connections
Every interaction with your database goes through a DbContext instance. You derive your own context class from DbContext and expose your database tables via DbSet<T> properties.
Here's a concrete example with two related entities -- a Blog and its associated Post records:
using Microsoft.EntityFrameworkCore;
public class BloggingContext : DbContext
{
public BloggingContext(DbContextOptions<BloggingContext> options)
: base(options)
{
}
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>(entity =>
{
entity.HasKey(b => b.BlogId);
entity.Property(b => b.Url).IsRequired().HasMaxLength(500);
});
modelBuilder.Entity<Post>(entity =>
{
entity.HasKey(p => p.PostId);
entity.Property(p => p.Title).IsRequired().HasMaxLength(200);
entity.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId);
});
}
}
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; } = string.Empty;
public List<Post> Posts { get; set; } = [];
}
public class Post
{
public int PostId { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int BlogId { get; set; }
public Blog Blog { get; set; } = null!;
}
The OnModelCreating method is where you configure your model using the Fluent API. You can also use Data Annotations (attributes directly on your entity classes), but the Fluent API gives you more control and keeps your domain models free of infrastructure concerns.
DbSet<T> and Entity Classes
DbSet<T> represents a table in your database. When you query context.Blogs, you're querying the Blogs table. When you add an entity to context.Blogs, EF Core queues it for insertion on the next SaveChanges call.
Your entity classes are plain C# classes -- POCOs (Plain Old CLR Objects). EF Core uses conventions to map them to database tables automatically. A property named Id or {ClassName}Id becomes the primary key by default. You don't need to inherit from any base class or implement any interface. Your domain objects stay clean.
Change Tracking
Change tracking is one of EF Core's most powerful -- and occasionally misunderstood -- features. When you load entities via a DbContext, EF Core records their original state in the change tracker. When you call SaveChangesAsync(), EF Core compares the current state of all tracked entities against their original snapshots and generates the minimum set of INSERT, UPDATE, and DELETE statements needed.
This means you often don't need to explicitly mark things as "modified." Just load an entity, change a property, and call SaveChangesAsync(). EF Core figures out what changed.
The trade-off: the change tracker has overhead. A DbContext that accumulates thousands of tracked entities can slow down significantly. For read-heavy scenarios where you never write back, call .AsNoTracking() on your queries to skip tracking entirely. It's one of the most impactful single-line performance optimizations in EF Core.
Registering EF Core in .NET 10
EF Core integrates cleanly with the .NET dependency injection system. In a typical .NET 10 application, registration lives in Program.cs:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
var app = builder.Build();
// Apply pending migrations on startup -- useful in dev and staging
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BloggingContext>();
await db.Database.MigrateAsync();
}
app.Run();
AddDbContext registers your DbContext with a scoped lifetime by default -- one instance per HTTP request. This is deliberate. DbContext is not thread-safe, and sharing a single instance across concurrent requests is a recipe for hard-to-debug data corruption.
When your service or controller requests BloggingContext from the DI container, the container injects the correct scoped instance automatically. If you're curious about how dependency injection containers wire this up at runtime, this deep dive on how DI containers use reflection internally pulls back the curtain on what's happening under the hood.
LINQ-Based Querying
LINQ translation is one of EF Core's biggest selling points. You write normal C# LINQ expressions, and EF Core translates them to SQL at runtime -- targeting the correct dialect for your configured provider.
Here's a practical example that queries posts for a given blog using filtering, sorting, and projection:
public record PostSummary
{
public int PostId { get; init; }
public string Title { get; init; } = string.Empty;
}
public async Task<List<PostSummary>> GetPostsByBlogAsync(
BloggingContext context,
int blogId,
int maxCount = 10)
{
return await context.Posts
.Where(p => p.BlogId == blogId)
.OrderBy(p => p.Title)
.Take(maxCount)
.Select(p => new PostSummary
{
PostId = p.PostId,
Title = p.Title,
})
.AsNoTracking()
.ToListAsync();
}
A few things worth highlighting here:
- Multiple
.Where()clauses compose -- they translate to AND conditions in the generated SQL .Select()with a projection means EF Core only selects the columns you actually need, not aSELECT *.AsNoTracking()skips change tracking entirely -- faster and lower memory consumption for reads.ToListAsync()is the point where the query actually executes against the database
That last point is critical. LINQ queries in EF Core use deferred execution -- the query is built up as an expression tree and only hits the database when you call a materializing method like ToListAsync(), FirstOrDefaultAsync(), or CountAsync(). Understanding LINQ deferred execution is essential to avoiding accidental N+1 queries and unintentional double enumeration bugs.
For a comprehensive reference on LINQ itself, the LINQ in C# complete guide covers everything from basic operators to complex joins and groupings. If you want to go deep on filtering specifically, LINQ filtering in C# walks through Where, Any, All, Contains, and OfType in depth -- all of which translate cleanly to SQL in EF Core.
EF Core 10 also added improved translation for more complex LINQ patterns, meaning fewer scenarios where EF Core has to warn you that part of your query will execute client-side rather than in the database.
Managing Schema with Migrations
EF Core migrations are how you keep your database schema in sync with your C# model as it evolves over time. Every time you change your entity classes or DbContext configuration, you create a new migration that describes the schema delta.
The standard workflow looks like this:
# Create a new migration after changing your model
dotnet ef migrations add AddBlogDescriptionColumn
# Apply all pending migrations to the target database
dotnet ef database update
# Generate a SQL script for review or production deployments
dotnet ef migrations script --idempotent --output migration.sql
Migrations live in a Migrations folder in your project by default. Each migration file contains two methods: Up() to apply the change and Down() to revert it. EF Core tracks which migrations have been applied in a __EFMigrationsHistory table in your database.
The --idempotent flag on the script command generates SQL that checks whether each migration has already been applied before running it -- very useful for production deployment pipelines where you want to run the script safely regardless of the current schema state.
One important rule: never manually edit generated migration files unless you know exactly what you're doing. If you need a custom operation (renaming a column, seeding data), add it after the generated code in Up() rather than modifying the auto-generated sections.
EF Core vs EF6 -- What Changed?
If you've worked with Entity Framework 6 (the classic, .NET Framework version), EF Core will feel familiar but different in important ways.
Where EF Core improved significantly:
- Performance -- EF Core is dramatically faster, especially for query compilation and bulk operations. Query result caching means repeatedly executed queries pay the compilation cost only once.
- Cross-platform -- runs on Linux, macOS, Docker containers. EF6 was Windows-only.
- Multiple providers -- first-class support for PostgreSQL, SQLite, MySQL, and others. EF6 was built around SQL Server.
- Modern C# support -- records, nullable reference types, value objects, collection expressions. EF6 predates most of these language features.
- Better query translation -- EF Core rarely falls back to client-side evaluation now. EF6 would silently execute large portions of queries in-process, fetching way more data than needed.
Things EF6 had that took EF Core time to match:
- Lazy loading (now supported in EF Core 10 via proxies or
ILazyLoader) - Many-to-many relationships without an explicit join entity (supported natively since EF Core 5)
- Complex table-splitting and owned entity scenarios (significantly improved in EF Core 8 and 9)
At this point, EF Core 10 is effectively a superset of EF6 for nearly all real-world use cases. There's almost no reason to start a new project with EF6.
EF Core vs Raw SQL and Dapper
EF Core is not the only tool for database access in .NET. Here's a clear-eyed view of when you might reach for something else.
Use EF Core when:
- You're doing standard CRUD operations on a well-defined domain model
- You want schema management and version control via migrations
- Your team prefers to stay in C# rather than writing SQL directly
- You need cross-database portability (e.g., SQLite for tests, SQL Server in production)
- You want change tracking to reduce boilerplate update code
Consider Dapper when:
- You need maximum query performance with minimal overhead
- You have complex SQL queries that EF Core translates poorly or cannot translate at all
- You're working with stored procedures, views, or dynamic SQL that doesn't map cleanly to entities
- Your team is SQL-first and prefers writing queries explicitly
Consider raw ADO.NET when:
- You need low-level connection and transaction control
- You're writing a database provider, tooling, or framework layer
- Every microsecond of performance overhead matters
The good news: you don't have to choose one exclusively. EF Core exposes FromSqlRaw and ExecuteSqlRawAsync for dropping down to raw SQL when you need it, while keeping all the benefits of the ORM for everything else. Many mature .NET applications use EF Core for standard data access and Dapper for complex reporting or analytics queries.
EF Core in Architectural Patterns
How you integrate EF Core into your application architecture matters as much as how you use it.
Direct DbContext Injection
The simplest approach is injecting DbContext directly into your services or minimal API endpoint handlers. This works well for small and medium-sized applications and is perfectly acceptable -- don't add abstraction layers you don't need.
public class BlogService(BloggingContext context)
{
public async Task<Blog?> GetBlogByIdAsync(int id)
{
return await context.Blogs
.Include(b => b.Posts)
.AsNoTracking()
.FirstOrDefaultAsync(b => b.BlogId == id);
}
public async Task<int> CreateBlogAsync(string url)
{
var blog = new Blog { Url = url };
context.Blogs.Add(blog);
await context.SaveChangesAsync();
return blog.BlogId;
}
}
Repository Pattern
For larger applications -- particularly when you need to abstract the data layer for testability or enforce module boundaries -- the repository pattern is a natural fit. The repository sits between your business logic and DbContext, exposing only the operations that make sense for each aggregate.
If you're building a modular monolith in C#, repositories become a key part of defining clean boundaries between modules. Each module owns its own DbContext (or its own set of tables via filtered queries) and exposes data to other modules through well-defined interfaces -- never by sharing DbContext instances across module boundaries.
There's an ongoing debate in the .NET community about whether to use repositories with EF Core, since DbSet<T> already acts as a repository in many respects. The answer depends on your team's needs and how much coupling to EF Core is acceptable in your business logic layer.
Logging EF Core Queries
Visibility into the SQL EF Core generates is essential for debugging query behavior and catching performance issues before they hit production. In .NET 10, EF Core integrates with the standard ILogger infrastructure:
builder.Services.AddDbContext<BloggingContext>(options =>
{
options.UseSqlServer(connectionString);
// Only enable in development -- exposes parameter values in logs
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
options.LogTo(
message => Console.WriteLine(message),
LogLevel.Information);
});
For production-grade logging, you'll want structured logs that capture SQL alongside request context like trace IDs, user IDs, and timing. The logging in .NET complete guide covers the full .NET logging pipeline, and Serilog in .NET shows you how to set up structured logging with sinks and enrichers -- which pairs naturally with EF Core's built-in query logging to give you full observability over your data layer.
What This Cluster Covers
This article is the hub of a nine-article deep dive on Entity Framework Core. Each spoke goes deep on one specific aspect of working with EF Core in production .NET applications.
Here's what the full cluster covers:
- Getting started -- installing packages, creating your first
DbContext, and connecting to SQL Server or SQLite from a clean project - CRUD operations -- create, read, update, and delete patterns with SaveChanges, the change tracker, and concurrency handling
- Migrations -- creating and applying migrations in development, staging, and production environments with safe deployment strategies
- LINQ querying -- filtering, sorting, projections, grouping, includes, and the generated SQL behind common query patterns
- Relationships -- configuring one-to-one, one-to-many, and many-to-many relationships with Fluent API and navigation properties
- Performance optimization -- AsNoTracking, compiled queries, split queries, bulk operations, and identifying N+1 patterns
- Testing -- unit testing with the in-memory provider, integration testing with SQLite, and mocking strategies for data access
- EF Core vs Dapper -- a detailed side-by-side comparison with real code examples for both tools to help you choose the right one
Each article works as a standalone reference once you understand the core concepts covered here. This hub gives you the map; the spokes give you the territory.
FAQ
What is Entity Framework Core used for?
Entity Framework Core is an ORM (object-relational mapper) used for database access in .NET applications. It lets you query and manipulate relational data using C# classes and LINQ instead of writing raw SQL. EF Core generates and executes the SQL, tracks changes to your entity objects, and manages database schema evolution via migrations. It works with SQL Server, SQLite, PostgreSQL, MySQL, and other providers.
Is EF Core suitable for production applications?
Yes -- EF Core is used in large-scale production applications across the industry, including at Microsoft itself. With proper use of .AsNoTracking() for read queries, compiled queries for hot code paths, and careful attention to N+1 query patterns, EF Core performs well at scale. The key is profiling the SQL EF Core generates in staging before it reaches production, not assuming the ORM always does what you expect.
What databases does EF Core support?
EF Core supports SQL Server, SQLite, PostgreSQL, MySQL, MariaDB, Oracle, and more via provider NuGet packages. There is also an in-memory provider used primarily for testing. The Microsoft-maintained providers for SQL Server and SQLite tend to be the most mature and best supported. PostgreSQL via Npgsql is also extremely well maintained and widely used in the .NET ecosystem.
How does EF Core relate to raw ADO.NET?
ADO.NET is the low-level database access API built into .NET -- you write SQL strings, open connections manually, execute commands, and read result rows yourself. EF Core sits on top of ADO.NET and automates all of that ceremony. You trade some raw flexibility for dramatically less boilerplate. For most applications the productivity gain is significant. For extreme performance-critical scenarios, EF Core exposes FromSqlRaw and ExecuteSqlRawAsync to drop to raw SQL while keeping the rest of the ORM working around it.
Should I use EF Core or Dapper?
Both are excellent tools with different trade-offs. EF Core is better when your application needs migrations, has a rich domain model, or your team prefers working entirely in C#. Dapper is better when you have complex SQL queries, existing stored procedures, or you need to squeeze maximum performance out of every query. Many .NET teams use both: EF Core for standard CRUD operations and Dapper for complex reporting, analytics, or stored-procedure calls. They compose well together.
What is a DbContext in EF Core?
DbContext is the primary class you work with in EF Core. It acts as a unit of work -- batching multiple changes into a single database transaction -- and as a session that manages the database connection. You derive a custom class from DbContext, expose DbSet<T> properties for each entity type (representing tables), and configure the model in OnModelCreating. EF Core registers it as a scoped service in the DI container, so you get one instance per HTTP request by default.
Do I need to use migrations with EF Core?
Migrations are the recommended approach for managing schema changes, but they're optional. You can call Database.EnsureCreated() to create the schema directly from your model without any migration history -- useful for test databases and quick prototypes. For production applications, migrations give you a versioned, auditable, reversible history of every schema change, which makes deployments far safer and debugging schema drift far easier.
Entity Framework Core has matured into one of the most capable ORMs in any platform ecosystem. Whether you're wiring up your first DbContext or optimizing a high-traffic production system, the investment in understanding EF Core deeply pays dividends across every .NET project you work on.

