Entity Framework Core (EF Core) is a cross-platform .NET ORM. A database provider maps its model and query pipeline to a specific engine. Microsoft ships providers for SQL Server, SQLite, and Azure Cosmos DB. PostgreSQL and MySQL support comes from external provider packages with their own compatibility schedules.
EF Core removes much routine mapping and persistence code. It does not make providers interchangeable or generated SQL harmless. Correct use depends on a short DbContext lifetime, deliberate tracking, inspected query shape, and migrations reviewed for the target engine.
Core Concepts
DbContext and DbSet
DbContext represents one session and unit of work. It owns the model, tracks selected entities, coordinates SaveChanges, and exposes provider services. DbSet<T> is a query and persistence entry point for an entity type. It is not a literal table abstraction because mappings can span tables, views, owned types, or provider-specific storage.
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<Order> Orders => Set<Order>(); public DbSet<Customer> Customers => Set<Customer>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Order>(b => { b.HasKey(o => o.Id); b.Property(o => o.Total).HasPrecision(18, 2); b.HasMany(o => o.LineItems).WithOne().HasForeignKey(li => li.OrderId); }); }}
Change Tracking
Entity queries are tracking by default. Entities also become tracked when they are added or attached. No-tracking queries and keyless entity types do not follow that default. A projection is untracked only when its result contains no entity instances. EF Core still tracks entity instances nested inside an anonymous or custom projection unless the query is explicitly no-tracking. SaveChangesAsync() detects changes in tracked entries and asks the provider to execute the required insert, update, or delete commands.
// Load → modify → save: EF Core detects the change automaticallyvar order = await db.Orders.FindAsync(orderId);order.Status = OrderStatus.Confirmed; // changes the CLR property; snapshot detection runs before saveawait db.SaveChangesAsync(); // generates: UPDATE Orders SET Status = 'Confirmed' WHERE Id = @id
With the default snapshot strategy, assigning Status does not itself notify EF Core. Automatic change detection compares current and original values before SaveChanges, at which point the entry and property are marked as modified. Notification-based tracking can detect changes earlier.
For a read-only query that does not need identity resolution or later updates through the same context, .AsNoTracking() avoids change-tracker work:
DbContext is a unit of work, not a singleton. It is not thread-safe, and EF Core does not support parallel operations on one instance. AddDbContext registers it as scoped by default in ASP.NET Core, which often aligns one context with one request. A request can still contain several units of work, and background or parallel work needs a fresh context from IDbContextFactory<T>. See the captive-dependency boundary in Dependency Injection. AddDbContextPool can reuse reset context instances, but mutable per-request state such as a tenant identifier must be re-established safely.
Transactions and Concurrency
When the provider supports transactions, one SaveChangesAsync() call is transactional by default: all generated changes commit or the call rolls them back. Several SaveChanges calls or a unit that mixes EF and SQL need an explicit shared transaction when they must have one atomic outcome:
await using var tx = await db.Database.BeginTransactionAsync(ct);try{ db.Orders.Add(order); await db.SaveChangesAsync(ct); await db.Database.ExecuteSqlAsync($"UPDATE Inventory SET Qty = Qty - 1 WHERE Id = {sku}", ct); await tx.CommitAsync(ct);}catch { await tx.RollbackAsync(ct); throw; }
Optimistic concurrency detects a stale update without holding a lock while application code works. A configured concurrency token, such as SQL Server rowversion, is included in the update or delete predicate. If the row no longer has the original token, the command affects zero rows and SaveChanges throws DbUpdateConcurrencyException. The application must reload, merge, retry from fresh state, or report a conflict. A token protects the rows included in the check. It is not a substitute for Serializable protection of an unvalidated predicate in ACID.
Provider execution strategies can retry transient database failures. When an application opens a transaction manually, the complete transactional unit must run through the configured execution strategy so a retry can recreate it safely. Business operations also need protection against an ambiguous commit result.
Migrations
EF Core migrations record model changes as C# operations and maintain a history table of applied migrations. Generated code is a starting point. Deployment still needs reviewed SQL, backups or a rollback path, and provider-specific handling for long-running or destructive changes.
# Create a migration after changing the modeldotnet ef migrations add AddOrderStatus# Apply pending migrations to the databasedotnet ef database update
Loading a tracked entity is unnecessary when the result only needs a few columns. A projection keeps the SQL result narrow and avoids creating a larger object graph:
// BAD: loads all columns including large blobsvar orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync();// GOOD: project to only needed columnsvar summaries = await db.Orders .Where(o => o.CustomerId == id) .Select(o => new OrderSummary(o.Id, o.Total, o.Status)) .ToListAsync();
Avoiding N+1 Queries
With lazy loading enabled, loading orders and then accessing each Customer navigation can issue one additional query per order.
// BAD: N+1 — one query for orders, one per order for customervar orders = await db.Orders.ToListAsync();foreach (var order in orders) Console.WriteLine(order.Customer.Name); // lazy load per order// GOOD: eager load with Includevar orders = await db.Orders .Include(o => o.Customer) .ToListAsync();
Bulk Updates and Raw SQL
The usual load-modify-save path materializes and tracks each affected entity. ExecuteUpdateAsync and ExecuteDeleteAsync perform a set-based command without loading those entities:
These methods bypass the change tracker, so matching entities already tracked by the context can become stale. Raw SQL is available when LINQ cannot express the required operation, but only parameterized APIs or correctly supplied parameters keep values out of SQL syntax. Raw fragments such as column names require validation because database parameters cannot represent identifiers.
Global Query Filters
A global query filter adds a model-level predicate to queries for an entity type. It can centralize soft-delete or tenant filtering, while explicit filter disabling remains a privileged escape hatch:
modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted && o.TenantId == _tenant.Id);// Opt out per-query with .IgnoreQueryFilters()
The filter is part of the security and correctness boundary when it carries tenant scope. IgnoreQueryFilters(), pooled-context tenant state, and filters on required navigations deserve tests because each can change which rows are returned.
Pitfalls
Lazy Loading in Production
What goes wrong: lazy loading is enabled and navigation properties are accessed in loops, causing N+1 queries. A page that loads 100 orders and accesses order.Customer for each can fire up to 101 queries. The full count occurs when the orders reference 100 distinct customers that the context has not already loaded; shared or already tracked customers can need fewer queries.
Why it happens: lazy loading is convenient in development but hides query patterns.
Mitigation: lazy loading is off unless it is configured. A projection suits a response with a fixed shape, and explicit loading of the required navigation is the alternative. Command counts in tests and telemetry confirm the actual query behavior, because an object traversal cannot be assumed to stay in memory.
Cartesian Explosion from Multiple Includes
Two sibling collection includes can produce a relational cross product. An order with 50 line items and 20 history rows can yield 1,000 rows before EF materializes the graph. Nested includes do not create the same sibling cross product, though they can still duplicate principal columns. AsSplitQuery() issues the root query plus separate commands for included collections, trading row multiplication for more commands and a possible consistency gap if data changes between them. A transaction with suitable isolation can close that gap when the graph must represent one database view.
Code First Vs Database First
Model and migrations: the EF model owns intended schema evolution, and reviewed migrations carry it forward.
Reverse engineering: dotnet ef dbcontext scaffold generates a model from an existing schema. Re-scaffolding and custom code need an ownership policy because generated files can change.
The schema owner is decided first. Application-owned schemas fit model-driven migrations. An externally governed or existing schema fits reverse engineering, often with partial classes or separate configuration to keep custom behavior out of regenerated code.
Zero-Downtime Migrations
Schema changes take provider-specific locks and may scan or rewrite a large table. Adding a required column, changing a type, building an index, or validating a constraint can exceed the deployment window even when the migration looks small. A generated migration does not establish that an operation is online.
Mitigation: an expand-contract rollout applies when old and new application versions must overlap:
A backward-compatible schema shape is added first, using the target engine’s online-safe procedure where one exists.
Next, a deployment ships code that can read the old and new shapes and writes the new value.
The backfill runs in bounded batches while locks, log growth, and replica lag are monitored.
The new invariant is validated, the column is made required when the engine can do so within the window, and obsolete compatibility code is then removed in a later release.
Questions
What lifetime should an EF Core DbContext have, and why?
An EF Core DbContext should live for one unit of work and serve one operation at a time. It is a session that tracks entities and coordinates SaveChanges for that unit of work. It is also not thread-safe, and EF Core does not support parallel operations on one instance.
Web requests: in ASP.NET Core, AddDbContext registers the context as scoped by default, so each request usually gets its own instance. One request can still contain several separate units of work.
Background or parallel work: each task needs its own fresh context, typically created through IDbContextFactory<TContext>, rather than a shared request instance.
Singletons: a singleton that captures a scoped context keeps one instance alive across requests, so concurrent requests can end up sharing it. This is the captive-dependency failure.
Context pooling:AddDbContextPool reuses reset context instances to lower setup cost. Per-request state, such as a tenant identifier, must therefore be re-established safely on each use.
How does EF Core change tracking work, and when is a no-tracking query appropriate?
EF Core change tracking records the entities that a DbContext loads, adds or attaches. It then compares their current values with remembered original values to decide what SaveChanges must write. A no-tracking query fits results that are only read and will not be updated through that context.
With the default snapshot strategy, tracking works in four steps:
Track: a tracking query, the default for entity queries, attaches each returned entity to the context and keeps a snapshot of its original values. If an entity with the same key is already tracked, EF Core returns that existing instance instead of a new object. This is identity resolution.
Modify: application code changes ordinary properties. Under this strategy, assigning a property does not notify EF Core.
Detect: before SaveChanges writes, automatic change detection compares current values with the snapshot and marks changed entities and properties as modified.
Save:SaveChanges sends the inserts, updates and deletes that the tracked entity states require.
.AsNoTracking() skips setting up this tracking information, which usually makes read-only queries cheaper. In exchange, SaveChanges neither detects nor saves changes to the returned objects. A plain no-tracking query also skips identity resolution, so one row returned twice becomes two separate objects. Later use of such a detached object for an update requires attaching it, stating which entity or properties are modified and handling concurrency. The context never recorded the object’s original values.
How does EF Core detect that another writer changed a row between reading it and saving an update?
EF Core detects the conflict through optimistic concurrency. The original value of a configured concurrency token is added to the WHERE clause of the UPDATE or DELETE. A command that then affects zero rows raises DbUpdateConcurrencyException. This detection does not require a database lock to span the read, the application’s work and the save. Whether any lock is held during that interval depends separately on the enclosing transaction and its isolation level.
Read: EF Core loads the row and remembers the token’s original value. In SQL Server, a rowversion column is a common token because the database changes it on every update.
Save: the generated command changes the row only if its key and token still equal the values that were read.
Conflict: if another writer updated the row in between, the token differs and the predicate matches nothing. EF Core treats the zero-row result as a conflict and throws DbUpdateConcurrencyException.
-- Simplified shape of the generated commandUPDATE OrdersSET Status = @newStatusWHERE Id = @id AND Version = @originalVersion;
The application then resolves the conflict. It reloads and merges values, retries the operation from fresh state, or reports the conflict.
Limit: the check covers only the rows that the command updates or deletes. It does not detect a concurrent change to other rows that a decision relied on, such as a newly inserted overlapping booking. Protecting such a predicate needs a stronger mechanism, such as Serializable isolation.
What is the N+1 query problem, and how can it be detected?
The N+1 query problem occurs when one query loads N parent rows and code then triggers a separate query for each parent’s related data. The result is N + 1 round trips instead of one or two. In EF Core it commonly comes from lazy loading: accessing a navigation inside a loop can issue a query per item.
var orders = await db.Orders.ToListAsync(); // 1 queryforeach (var order in orders) Console.WriteLine(order.Customer.Name); // with lazy loading: can query once per order
Each extra query adds a round trip, so the cost grows with the number of parents. A page that seems fast with little test data can degrade as production data grows.
Detection:
Command count per operation: counting the database commands that one request or test issues reveals N+1 when the count grows with the number of parent rows.
Logged SQL: the log shows the same statement repeated many times, with only the key parameter changing.
Traces: one request contains many short, sequential database spans.
Fixes reduce the round trips. Options include a projection that selects only the related columns the result needs, an Include that eager-loads the navigation, or a deliberate second query that loads the related rows for all parents at once. The right choice depends on the result shape and consistency needs. No single collection-size threshold decides it.