Mid-level 5 min · March 06, 2026

Entity Framework Core Basics: DbContext, Migrations & Queries Explained

Entity Framework Core explained from the ground up — DbContext setup, migrations, LINQ queries, and real-world patterns every ASP.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • EF Core is an ORM that maps C# objects to database tables
  • DbContext manages connections and tracks changes
  • Migrations evolve the schema as your model changes
  • LINQ queries compile to SQL — but N+1 queries destroy performance
  • Change tracking works on the context level — don't reuse contexts across requests
  • Biggest mistake: calling ToList() too early, loading entire tables into memory
Plain-English First

Imagine you run a restaurant and you have a filing cabinet full of customer order cards. Every time you want a record, you flip through folders manually. Entity Framework Core is like hiring a brilliant assistant who speaks both your language ('Get me all orders from last Tuesday') and the filing cabinet's language ('SELECT * FROM Orders WHERE date = ...'). You never touch the cabinet directly — your assistant translates everything both ways. That's EF Core: it lets you talk to a database using plain C# objects instead of raw SQL.

Every real-world ASP.NET application eventually needs to store data — user profiles, orders, blog posts, inventory. The question isn't whether you'll talk to a database, it's how painful that conversation will be. Writing raw ADO.NET SQL by hand works, but it's brittle: typos in query strings only blow up at runtime, schema changes mean hunting down magic strings across fifty files, and mapping result rows to C# objects is pure boilerplate. That's the world before Object-Relational Mappers (ORMs).

Entity Framework Core solves this by letting you define your data model as regular C# classes, then automatically generating the SQL, handling connections, and mapping query results back to those same objects. It also tracks changes — so when you modify a property on an object and call SaveChanges(), EF Core figures out the exact UPDATE statement needed. It bridges the fundamental mismatch between how databases think (tables and rows) and how C# thinks (objects and properties).

By the end of this article you'll have a fully wired ASP.NET Core app using EF Core with SQLite: a working DbContext, a real database migration, typed LINQ queries, and a proper understanding of why each piece exists. You'll also know the three most common mistakes that burn developers in production — and exactly how to avoid them.

What Is DbContext and How Do You Set It Up?

DbContext is the bridge between your C# code and the database. Think of it as a session — it holds the connection, tracks changes to entities, and manages identity mapping (two references to the same row return the same object).

Setting up a DbContext involves three things: a class that inherits from DbContext, a set of DbSet properties for each table, and a connection string. Here's a minimal example for SQLite:

```csharp using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext { public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlite("Data Source=app.db"); } } ```

In ASP.NET Core, you never call OnConfiguring manually. Instead, register the context in the DI container:

``csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); ``

Now controllers can inject AppDbContext directly. Each request gets a fresh context—this is critical because the change tracker accumulates state over time. A stale context leads to memory leaks and stale data.

Forge Tip:
Always scoped DbContext lifetime per request. A singleton context will corrupt your data because the change tracker never resets.
Production Insight
Context pooling in .NET 6+ reuses context instances internally.
But pooling doesn't reset navigation properties — you can still get leaked references.
Always call context.ChangeTracker.Clear() for long-running batch jobs.
Ignore this and your memory graph grows until OOM kills the app.
Key Takeaway
DbContext = database session per request.
Connection string goes in appsettings.json.
Register with AddDbContext, never manually new-up a context.
Use .UseSqlite(), .UseSqlServer(), or .UseNpgsql() for PostgreSQL.

Migrations: Evolving the Schema Without Losing Data

Migrations let you version-control your database schema. When you change a C# model (add a property, rename a column), EF Core generates a migration file with Up() and Down() methods. You apply them to the database with:

``bash dotnet ef migrations add AddOrderDate dotnet ef database update ``

Under the hood, EF Core reads your current model snapshot and compares it to the database. It generates the exact ALTER TABLE statements needed. The __EFMigrationsHistory table tracks which migrations have been applied.

Production gotcha: never run EnsureCreated() in production. It skips migrations entirely and creates the schema from scratch — losing all data if the table already exists. Use Migrate() or run migrations as part of deployment scripts.

Migrations/20240306000000_AddOrderDate.csC#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using Microsoft.EntityFrameworkCore.Migrations;

public partial class AddOrderDate : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<DateTime>(
            name: "OrderDate",
            table: "Orders",
            type: "TEXT",
            nullable: false,
            defaultValueSql: "datetime('now')");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "OrderDate",
            table: "Orders");
    }
}
Production Insight
Running migrations automatically at startup (context.Database.Migrate()) locks the database.
In a scaled-out web farm, multiple instances race — one wins, others fail.
Run migrations as a separate startup step in your CI/CD pipeline, not at app start.
Use IDatabaseMigrator or a dedicated migration container.
Key Takeaway
Migrations are version control for your schema.
Always generate a migration after every model change.
Never use EnsureCreated() in production.
Apply migrations during deployment, not at app startup if you have multiple instances.

Writing Queries with LINQ — The Right Way

EF Core translates LINQ queries into SQL. That's the whole point — you write C# and it becomes WHERE, JOIN, GROUP BY. But not all LINQ methods translate. Where(), Select(), Join(), OrderBy() work. FirstOrDefault(), ToList(), Count() execute immediately (they force query execution).

Here's a query that fetches all orders from last week with their item counts:

``csharp var lastWeek = DateTime.UtcNow.AddDays(-7); var orders = await context.Orders .Where(o => o.OrderDate >= lastWeek) .Select(o => new { o.Id, o.CustomerName, ItemCount = o.OrderItems.Count }) .ToListAsync(); ``

This generates a single SQL query with a subquery for the count. Notice we project to an anonymous type — that's key. If we had said .Include(o => o.OrderItems).ToList(), we'd load all items into memory just to count them.

Deferred execution trap: LINQ queries are not executed until you iterate or call a terminal method. That's why storing an IQueryable and then modifying it later can surprise you. If you change a variable used in the query before it's executed, the query picks up the new value.

Mental Model: IQueryable as a Recipe
  • Building a LINQ chain adds ingredients to the recipe — no SQL is generated yet.
  • ToQueryString() shows the recipe (SQL) before cooking.
  • ToListAsync() is the oven — it executes the recipe and returns ingredients.
  • You can hand the recipe around, add more clauses, but if you cook it twice you get two separate SQL executions.
  • A stale variable captured in a closure? The recipe uses the current value at cooking time, not at recipe-building time.
Production Insight
Projecting to anonymous types with Select() avoids loading entire entity graphs.
Using ToList() prematurely in a loop causes N+1 — query per iteration.
Always compose the full query before materializing.
If you must iterate, use AsEnumerable() to force client-side evaluation only at the end.
Key Takeaway
LINQ compiles to SQL — use projection to avoid overfetch.
Defer execution until the last moment.
Never call ToList() inside a loop.
Use ToQueryString() to debug the generated SQL before you ship.

Change Tracking: How EF Core Knows What to UPDATE

When you load an entity with a SELECT, EF Core takes a snapshot of its property values. Then you modify one property (e.g., order.Status = "Shipped"). When you call SaveChangesAsync(), EF Core compares current values to the snapshot and generates a SQL UPDATE for only the changed columns.

But here's the trap: if you load an entity, detach it from the context, then re-attach it later, EF Core assumes all properties are modified unless you tell it otherwise. That means it generates an UPDATE setting every column — even the ones that didn't change.

``csharp public async Task UpdateOrderAsync(Order modifiedOrder) { var existing = await context.Orders.FindAsync(modifiedOrder.Id); // Map modified fields explicitly existing.Status = modifiedOrder.Status; existing.ShippedDate = modifiedOrder.ShippedDate; await context.SaveChangesAsync(); } ``

Always load the existing entity and copy properties. Never attach a disconnected entity unless you're okay with full-column updates. For APIs that receive the full object, consider using AutoMapper with a clear map or manual assignment.

Disconnected scenarios (e.g., REST APIs) are the number one cause of unintended updates. The context has no idea which properties the client changed — it only sees the final object.

Production Insight
Disconnected entities cause full-column UPDATEs.
Always fetch the existing entity first, then apply only the changed properties.
If you must use Attach(), set all properties to modified explicitly — risky.
Use DTOs and projection to avoid the issue entirely in read-only paths.
Key Takeaway
Change tracking is automatic but only on entities loaded by the same context.
Disconnected entities = all columns modified.
Fix: load, map, save.
Never trust client-sent values blindly.

Lazy Loading vs Eager Loading vs Explicit Loading

EF Core gives you three ways to load related data. Each has cost:

  • Eager loading: Use .Include() and .ThenInclude() to load related entities in one SQL query. Best for read-only UIs where you know you need the data. But too many Includes cause cartesian explosion (rows multiplied).
  • Lazy loading: Navigation properties are loaded on first access. Requires services.AddDbContext<...>(options => options.UseLazyLoadingProxies()). Convenient, but each access generates a separate SQL query — classic N+1 problem.
  • Explicit loading: You manually call .Load() on a collection reference. Gives you control: "Load all OrderItems for these orders in one batch".

Rule of thumb: Start with eager loading. If you see duplicate data or huge result sets, switch to explicit loading. Never enable lazy loading by default in production — it's a performance landmine.

```csharp var orders = await context.Orders.Take(100).ToListAsync(); var orderIds = orders.Select(o => o.Id).ToList(); var orderItems = await context.OrderItems .Where(i => orderIds.Contains(i.OrderId)) .ToListAsync();

foreach (var order in orders) order.OrderItems = orderItems.Where(i => i.OrderId == order.Id).ToList(); ```

Three queries, total data transferred exactly what's needed — no joins, no duplicates.

Production Insight
Lazy loading in a loop = N+1 queries, kills database performance.
Eager loading with too many Includes = cartesian explosion, huge network transfer.
Explicit loading + batch loading = the middle ground most apps should use.
Profile before deciding — use EF Core's ToQueryString() and SQL Server Profiler.
Key Takeaway
Prefer eager loading for simple joins.
Switch to explicit loading when Includes start joining 3+ tables.
Never enable lazy loading in production by default.
Always batch explicit loads with Where(ids.Contains(...)).
● Production incidentPOST-MORTEMseverity: high

The Slow Dashboard That Took Down Production

Symptom
The admin dashboard (/admin/orders) started timing out after deployment. CPU on the SQL Server pinned at 100%. Connection pool exhausted within minutes.
Assumption
The team assumed EF Core's Include() was always the right choice — 'join everything in one query is fast'.
Root cause
A new developer added .Include(o => o.OrderItems).ThenInclude(i => i.Product) on a query returning 5,000 orders. EF Core generated a SQL query with 12 LEFT JOINs, returning 50,000 rows. Every row was identical for order columns, leading to massive bandwidth waste and client-side object materialisation overhead.
Fix
Switched to explicit loading for the product details: load orders first, then batch-load OrderItems in a single round trip using .Where().ToList(). Then load Products separately. Total SQL: three small queries, total time dropped to 200ms.
Key lesson
  • Include() is not free — it multiplies rows by the cardinality of each navigation property.
  • Use .AsSplitQuery() in EF Core 5+ to avoid cartesian explosion when including multiple collections.
  • Profile every Include() with SQL Server Profiler or IQueryable.ToQueryString() before shipping.
  • For read-only dashboards, consider a dedicated DTO projection with Select() — no need to track entities.
Production debug guideSymptom-to-action for the three most common production slowdowns4 entries
Symptom · 01
Page load time spikes after adding .Include()
Fix
Check generated SQL with _context.ChangeTracker.DebugView.LongView or log the query with ToQueryString(). Look for excessive JOINs. Add .AsSplitQuery() or switch to explicit loading.
Symptom · 02
Memory grows steadily over time on the web server
Fix
Enable context pooling (AddDbContextPool) to reuse contexts. Verify you're not holding references to tracked entities in static caches or singletons. Call context.ChangeTracker.Clear() between large batch operations.
Symptom · 03
Database deadlocks under moderate load
Fix
Check transaction isolation levels. EF Core uses ReadCommitted by default — upgrade to Snapshot isolation for read-heavy workloads. Ensure updates are ordered consistently to avoid cycle deadlocks.
Symptom · 04
SaveChangesAsync() takes >1 second
Fix
Monitor the number of tracked entities. If you're saving 10,000 entities in one call, batch them: split into chunks of 500 using SaveChangesAsync per chunk. Alternatively, use EF Core Plus BulkInsert.
★ EF Core Quick Debug CommandsCommands to diagnose common EF Core issues in production
Generated SQL is wrong or unexpected
Immediate action
Log the query to the console or a debug output
Commands
var query = context.Orders.Where(o => o.Status == "Pending"); var sql = query.ToQueryString(); Console.WriteLine(sql);
Enable logging in ConfigureServices: services.AddDbContext<AppDbContext>(options => options.UseSqlite("Data Source=app.db").LogTo(Console.WriteLine, LogLevel.Information));
Fix now
Review the generated SQL. If there are unexpected JOINs, remove unnecessary Include() calls or add .IgnoreAutoIncludes() to suppress conventions.
N+1 queries detected in logs+
Immediate action
Identify which navigation property triggers repeated queries
Commands
Enable sensitive data logging: optionsBuilder.EnableSensitiveDataLogging(). Then check logs for repeated SELECT statements for the same entity type.
Use .Include() for eager loading or .Load() for explicit loading with a batch: var orders = await context.Orders.ToListAsync(); var orderIds = orders.Select(o => o.Id); var items = await context.OrderItems.Where(i => orderIds.Contains(i.OrderId)).ToListAsync();
Fix now
Replace lazy loading with explicit loading or eager loading. Ensure the the navigation property is not virtual if lazy loading is disabled.
SaveChanges fails with DbUpdateConcurrencyException+
Immediate action
Check the original values and database values
Commands
catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(); var originalValues = entry.OriginalValues; // compare properties } }
Decide on conflict resolution: client wins, database wins, or custom merge. Implement retry logic with Polly if needed.
Fix now
Use a timestamp or row version column (concurrency token) to detect conflicts before saving. Configure with [Timestamp] attribute or IsRowVersion() fluent API.
Context disposed exception (ObjectDisposedException)+
Immediate action
Check where the context is being used after disposal
Commands
Ensure context is scoped per request in ASP.NET Core: services.AddDbContext<AppDbContext>(options => ...). Do not store context in static fields or singletons.
If using dependency injection, never manually dispose context — the DI container handles disposal at the end of the request scope.
Fix now
Remove any using() blocks around DbContext in ASP.NET Core controllers. Let DI manage lifetime.
Loading Strategies Compared
StrategySQL QueriesWhen to UseRisk
Eager Loading (.Include)1 query with JOINsSmall result sets, known navigationsCartesian explosion
Lazy Loading (virtual)1 query per navigation accessRapid prototyping onlyN+1 per listing
Explicit Loading (.Load())Multiple small queriesLarge datasets, control neededSlightly more code

Key takeaways

1
DbContext is scoped per request
never share across requests.
2
Migrations are the only safe way to evolve production schemas.
3
Project with Select() to avoid loading entire entity graphs.
4
Explicit loading beats eager loading when you have large result sets.
5
Never enable lazy loading in production without understanding N+1.
6
Always call SaveChangesAsync() and handle concurrency exceptions.

Common mistakes to avoid

4 patterns
×

Reusing DbContext across requests

Symptom
Stale data, memory growth, context disposed exceptions. The change tracker accumulates thousands of entities over time.
Fix
Register DbContext as scoped in DI (AddDbContext). Each request gets a new context. Never store context in static variables or singletons.
×

Using Include() on every navigation without considering row duplication

Symptom
SQL query returns millions of rows from a 10,000-row table because .Include chains multiply rows. Application slows to a crawl.
Fix
Use .AsSplitQuery() in EF Core 5+ to issue one query per include instead of a massive join. Or use explicit loading with batch queries.
×

Forgetting to call SaveChangesAsync() after modifications

Symptom
Data appears unchanged. The developer modified entity properties but never persisted. No error — just silent data loss.
Fix
Always call SaveChangesAsync() after any mutation. Use a Unit of Work pattern or an interceptor to ensure SaveChanges is called exactly once per operation.
×

Not using projections (Select) for read-only data

Symptom
Entire entity graphs loaded into memory just to display a few fields. High memory usage on web server.
Fix
Use .Select(dto => new OrderListDto { Id = o.Id, CustomerName = o.CustomerName }) to fetch only needed columns. No change tracking overhead.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between AddDbContext and AddDbContextPool? When w...
Q02SENIOR
Explain how EF Core generates SQL from LINQ. What happens when a LINQ me...
Q03SENIOR
How would you handle optimistic concurrency in EF Core? Provide a code e...
Q04SENIOR
What is the difference between IQueryable and IEnumerable in EF Co...
Q01 of 04SENIOR

What is the difference between AddDbContext and AddDbContextPool? When would you use each?

ANSWER
AddDbContext creates a new context instance per request. AddDbContextPool reuses context instances — the pool resets state (ChangeTracker, connection) before handing out a context. Use pooling in high-throughput scenarios to reduce overhead of constructing and tearing down contexts. However, pooling has a reset cost; if your requests are short, the reset may outweigh benefits. Measure before adopting.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is Entity Framework Core in simple terms?
02
Do I need to write SQL when using EF Core?
03
What is the difference between EF Core and Entity Framework 6?
04
How do I enable logging to see the generated SQL?
05
Can I use EF Core with existing databases?
🔥

That's ASP.NET. Mark it forged?

5 min read · try the examples if you haven't

Previous
Middleware in ASP.NET Core
4 / 14 · ASP.NET
Next
Authentication in ASP.NET Core