Anyone can memorize the EF Core cheat sheet: call AsNoTracking for reads, wrap SaveChanges in a try/catch, add an index when things are slow. What separates a senior candidate is understanding why those rules exist — what the change tracker is actually doing between a query and a save, how identity resolution can silently return stale data, and what a DbContext really is under the DI container's Scoped lifetime. Interviewers use EF Core internals questions to find out whether you've only used the ORM or whether you understand the machinery well enough to debug it at 2 a.m. in production. The ten questions below are the ones that separate "I've used EF Core for five years" from "I actually know what it's doing."
Q1 How does EF Core's change tracker detect changes, and what does that cost you?#
Short answer: EF Core uses snapshot-based change tracking by default: when an entity is materialized or attached, the tracker takes a snapshot of its property values, and ChangeTracker.DetectChanges() later compares the entity's current values against that snapshot to compute an update. DetectChanges runs automatically before SaveChanges and before most LINQ query executions, and its cost scales with the number of tracked entities.
The alternative is notification-based tracking, where your entities implement INotifyPropertyChanging/INotifyPropertyChanged and raise events on every setter. EF Core then updates the change tracker incrementally instead of doing a full snapshot comparison, which is faster for graphs with thousands of tracked entities but requires more invasive entity classes. Almost nobody does this in practice; snapshot tracking is simpler and fast enough for the vast majority of applications.
The practical cost shows up when a context accumulates a large number of tracked entities — a long-lived context in a batch job, for example. Every call that triggers DetectChanges walks every tracked entry. You can disable automatic detection with ChangeTracker.AutoDetectChangesEnabled = false and call DetectChanges() manually at controlled points, which is a legitimate optimization for bulk-loading scenarios, but it's easy to introduce subtle bugs if you forget to call it before something that depends on tracked state, like a foreign key fix-up.
context.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var row in importedRows)
{
context.Orders.Add(MapToOrder(row));
}
context.ChangeTracker.DetectChanges();
await context.SaveChangesAsync();What interviewers look for: whether you know tracking is snapshot-based by default, that DetectChanges has a real cost proportional to tracked-entry count, and that disabling it is a deliberate, narrow optimization rather than a default habit.
- Common mistakes: assuming EF Core tracks changes "live" via property setters; disabling
AutoDetectChangesEnabledbroadly and then wondering whyAddedentities never get inserted. - Follow-up questions: How would you profile how many entities a context is tracking? What happens to memory if you never dispose a context that accumulates tracked entities?
Q2 What is identity resolution, and why can two queries for "the same" row return different data?#
Short answer: A DbContext acts as an identity map: it can track only one instance per entity type and primary key value, so when a tracking query returns a row whose key is already tracked, EF Core hands back the existing instance instead of creating a new one — and it does not overwrite that instance's current values with what just came back from the database.
This has a concrete, interview-worthy consequence: if another process updated the row after your context first loaded it, a second tracking query against the same context returns the original in-memory values, not the fresh database values, because identity resolution short-circuits materialization once an entry for that key already exists. This is a frequent source of "EF Core is caching stale data" bug reports that are actually working exactly as designed.
No-tracking queries behave differently: by default they skip identity resolution entirely, so a query that returns the same logical row multiple times (through a join, for instance) produces a separate object instance for each occurrence. If you need deduplicated results without full tracking overhead, AsNoTrackingWithIdentityResolution() uses a standalone, short-lived change tracker just for the duration of that query to resolve duplicates, then discards it — the results still aren't tracked by the context afterward.
var first = await context.Blogs.FirstAsync(b => b.Id == 1); // hits the database
var again = await context.Blogs.FirstAsync(b => b.Id == 1); // returns the SAME instance,
// no new database values appliedWhat interviewers look for: a precise mental model of the identity map, and awareness that tracking queries never silently refresh an already-tracked instance — you have to Reload() or requery with a fresh context.
- Common mistakes: believing repeated queries always reflect the latest database state; not knowing
AsNoTrackingWithIdentityResolutionexists as a middle ground. - Follow-up questions: How would you force-refresh a tracked entity's values from the database? Why don't no-tracking queries do identity resolution by default?
Q3 Walk through what actually happens between calling ToListAsync() and getting entities back.#
Short answer: Your LINQ expression becomes an expression tree, which EF Core's query pipeline compiles into a relational command tree, translates to provider-specific SQL, executes over ADO.NET, and then materializes rows back into CLR objects — attaching them to the change tracker if the query is a tracking query. The expensive compilation step is cached by expression-tree shape, so repeated executions of structurally identical queries skip most of that work.
Concretely, the stages are: (1) LINQ operators build an IQueryable expression tree rather than executing anything; (2) when the query is enumerated, EF Core's query compiler walks the tree, applies query-model optimizations (predicate pushdown, navigation expansion for Include), and produces a relational expression; (3) the relational provider (SQL Server, PostgreSQL, SQLite...) translates that into a parameterized SQL string; (4) the compiled result — including a "shaper" delegate that knows how to build your CLR types from a DbDataReader — is cached keyed by the tree's shape, not its parameter values, which is why changing a constant embedded directly in the query (rather than a captured variable) causes a fresh compilation and a new cache entry; (5) ADO.NET executes the command; (6) the shaper materializes each row, and, for tracking queries, the change tracker performs identity resolution and fix-up of navigation properties as each entity is produced.
Any part of the query that can't be translated to SQL either throws (server-side evaluation is required for nearly everything since EF Core 3.0) or, for a narrow set of top-level projection scenarios, falls back to client evaluation after the data is fetched.
What interviewers look for: that you can separate "building an expression tree" from "compiling to SQL" from "materializing," and that you understand why literal constants versus parameters affect the query cache.
- Follow-up questions: Why does EF Core no longer support client evaluation inside a
Whereclause the way EF6 did? What's the difference between the query plan cache and a compiled query?
Q4 What are compiled models, and when do they actually pay off?#
Short answer: Compiled models are a pre-built, source-generated representation of your IModel that skips the reflection-heavy model-building step EF Core normally runs the first time a DbContext type is used, cutting cold-start time for applications with very large models — think hundreds to thousands of entity types, not a typical CRUD app.
You generate one with the dotnet ef dbcontext optimize command, optionally specifying --output-dir and --namespace; the tool emits a partial class exposing a static Instance and prints the exact optionsBuilder.UseModel(...) call to wire it into OnConfiguring. The generated model is a snapshot: it does not regenerate itself, so every time you change your entity configuration you must rerun the command or your app silently keeps using the stale compiled model.
Compiled models come with real limitations that matter in a design discussion: global query filters aren't supported, lazy-loading and change-tracking proxies aren't supported, value converters that close over private methods break, and custom IModelCacheKeyFactory implementations aren't supported. Because of this, they're a targeted fix for a measured startup-time problem — for example, an Azure Functions or serverless workload where cold start directly costs money — not a default you reach for on every project.
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModelsprotected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseModel(MyApp.CompiledModels.AppDbContextModel.Instance);What interviewers look for: the ability to say "this solves a specific, measured problem" instead of treating it as free performance, plus knowledge of at least two limitations.
- Common mistakes: enabling compiled models on a small model where the win is negligible; forgetting to regenerate after a migration, causing the running model to drift from the actual schema mapping.
Q5 How do interceptors work, and how are they different from logging?#
Short answer: Interceptors let you observe and modify or suppress specific EF Core operations — command execution, connection handling, transactions, SaveChanges, entity materialization, query expression trees and identity resolution — whereas logging (ILogger integration or simple logging via LogTo) is strictly observational. If you need to rewrite a SQL command, inject a query hint, or short-circuit a database round trip, you need an interceptor; if you just need visibility, logging is simpler and has lower overhead.
You register interceptors with optionsBuilder.AddInterceptors(...), typically inside OnConfiguring, which still runs even when the context is constructed through AddDbContext or an externally-built DbContextOptions. Most interceptor interfaces (IDbCommandInterceptor, IDbConnectionInterceptor, IDbTransactionInterceptor, ISaveChangesInterceptor) are registered per context instance, but three — IMaterializationInterceptor, IQueryExpressionInterceptor and IIdentityResolutionInterceptor — implement ISingletonInterceptor and are wired into EF Core's internal service provider. That distinction matters operationally: passing a new instance of a singleton interceptor every time a context is configured causes EF Core to build a new internal service provider each time, which eventually triggers a ManyServiceProvidersCreatedWarning and degrades performance. The fix is to hold a single static readonly instance and reuse it.
public class QueryTagInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
command.CommandText = $"/* tenant:{TenantContext.Current} */\n{command.CommandText}";
return result;
}
}What interviewers look for: the observe-vs-modify distinction, and awareness of the singleton-interceptor pitfall, which is a common source of a hard-to-diagnose performance regression in real codebases.
- Follow-up questions: How would you use an interceptor to implement audit logging on
SaveChanges? What's the difference between an interceptor and a diagnostic listener?
Q6 What's the real difference between owned entity types and complex types?#
Short answer: Both let you model a value object — an address, money amount — that lives inside an entity without its own table, but owned types are, under the hood, still entity types with an identity, while complex types (EF Core 8+) have true value semantics: they're compared and copied by their contents, not by reference.
That difference isn't academic. With owned types, assigning one owned instance to another property on the same entity fails, because the same owned entity instance can't be tracked twice; with complex types, the same assignment just copies the values across, exactly as you'd expect from a value type. Owned types don't support bulk ExecuteUpdate against their properties; complex types fully support it, including when mapped to a JSON column via ToJson(). Comparing two owned instances in a LINQ query compares identities and doesn't do what most developers expect; comparing two complex-type instances compares their contents.
EF Core 10 extended complex types further: they now support optional complex types (nullable, provided at least one member is required), mapping to JSON columns on providers that support a native json type, and mapping .NET struct types directly — though collections of structs still aren't supported. Given all of this, Microsoft's own guidance is to prefer complex types over owned entity types for new table-splitting or JSON-document modeling, and to consider migrating existing owned-type usage where the reference-semantics quirks have caused bugs.
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(c => c.ShippingAddress);
b.ComplexProperty(c => c.BillingAddress, a => a.ToJson());
});What interviewers look for: knowing the value-vs-reference distinction concretely (not just "they're similar"), and that this is a live, evolving part of the EF Core model that changed materially in recent releases.
- Common mistakes: treating owned types and complex types as interchangeable; not knowing complex types can map to JSON.
Q7 What goes wrong with lazy loading in real applications?#
Short answer: Lazy loading trades an obvious Include for an invisible query that fires the moment a navigation property is touched — which reliably produces N+1 query storms in loops, and can throw once the originating DbContext has been disposed, which is exactly what happens if a lazy-loading entity escapes into a serializer after the request scope ends.
To enable it you install Microsoft.EntityFrameworkCore.Proxies, call UseLazyLoadingProxies(), and mark navigation properties virtual so EF Core can generate a runtime proxy subclass that intercepts property access. For POCOs that can't be made virtual (sealed classes, records), you can instead inject ILazyLoader through the constructor and call it explicitly — more code, but it avoids the proxy machinery.
The two failure modes that come up constantly in real systems: first, iterating a collection of parent entities and touching a lazy navigation inside the loop turns one query into N+1, often invisibly, because nothing in the code looks like a query. Second, returning a lazy-loaded entity graph from a controller action and letting the JSON serializer walk its navigation properties either throws (context already disposed by the time serialization runs) or triggers a wave of synchronous, blocking database calls on the request thread, and can recurse into cycles if navigations are bidirectional. Most teams that adopt lazy loading early end up disabling it later in favor of explicit Include or projection, specifically because the N+1 pattern is so easy to introduce by accident and so hard to spot in code review.
What interviewers look for: a concrete story of why lazy loading is discouraged in high-throughput systems, not just "it's slow" — the N+1-in-a-loop and disposed-context-during-serialization scenarios are the two that show real experience.
- Common mistakes: enabling lazy loading "for convenience" on API-layer entities that get serialized directly.
- Follow-up questions: How would you detect N+1 queries in code review versus at runtime? What's the equivalent of lazy loading if you're using no-tracking, read-only DTO projections?
Q8 Is DbContext thread-safe, and how should its lifetime be managed?#
Short answer: No — a DbContext instance is not thread-safe and doesn't support multiple concurrent operations; using the same instance from two threads at once (or issuing a second async call before the first completes) throws an InvalidOperationException reporting that an operation was started before a previous one finished. In ASP.NET Core, AddDbContext registers the context with a Scoped lifetime by default, meaning you get one instance per HTTP request (or per DI scope), which naturally keeps it single-threaded per unit of work as long as you don't fan work out across parallel tasks sharing one instance.
That scoping breaks down in a few common places: Task.WhenAll over several queries that all use the same injected context; Blazor Server, where a circuit can outlive a conventional request scope; and background workers or console apps that have no ambient DI scope to hook into. For those, IDbContextFactory<TContext> is the right tool — register it with AddDbContextFactory, and call CreateDbContext() (or CreateDbContextAsync()) to get a short-lived, disposable context per unit of work, even many of them concurrently from the same factory.
builder.Services.AddDbContextFactory<AppDbContext>(options => options.UseSqlServer(connectionString));
// later, from a singleton service or a Blazor component
await using var context = await _factory.CreateDbContextAsync();
var result = await context.Orders.CountAsync();What interviewers look for: that you know the why — DbContext maintains mutable internal state that's not synchronized — and that you can name at least one scenario (Blazor Server, background jobs, parallel task fan-out) where the default Scoped pattern isn't enough.
- Common mistakes: injecting a Scoped
DbContextinto a Singleton service (a DI captive-dependency bug the container will refuse at runtime); sharing one context acrossTask.WhenAll.
Q9 How does SaveChanges decide the order of the generated INSERT, UPDATE and DELETE statements?#
Short answer: EF Core builds a dependency graph from the tracked entities' foreign-key relationships and entity states, then topologically sorts it so that inserts of principal (parent) rows happen before inserts of dependents that reference them, and deletes of dependents happen before deletes of the principals they point to — you never have to specify the order yourself, and getting it wrong would violate foreign-key constraints anyway.
Within that ordering, EF Core also batches statements where the provider supports it — the SQL Server provider, for example, batches multiple inserts/updates/deletes into fewer round trips up to a configurable batch size — and wraps the whole SaveChanges call in an implicit transaction by default (controllable via Database.AutoTransactionBehavior, with values WhenNeeded, Always and Never). If SaveChanges is invoked while a transaction is already in progress on the context, EF Core automatically creates a savepoint first, so a failure partway through can roll back just that unit of work instead of the entire outer transaction — which matters directly for retrying after an optimistic concurrency conflict.
Interview-worthy edge case: a self-referencing table (an Employee with a nullable ManagerId pointing at another Employee) can create a cycle the topological sort can't resolve in one pass; EF Core handles the common case of a nullable FK by inserting rows first with a null FK and then issuing a follow-up UPDATE, but a required self-reference or a genuine cross-table cycle will throw at SaveChanges time, and you have to break the cycle manually — often by making the FK nullable or deferring one relationship to a second SaveChanges call.
What interviewers look for: knowing this is graph-dependency-driven rather than "insertion order," and being able to reason about the self-referencing-table edge case, which is a good proxy for real production experience.
- Follow-up questions: How would you diagnose a
DbUpdateExceptioncaused by ordering in a large graph? What role do savepoints play when retrying afterDbUpdateConcurrencyException?
Q10 What's the practical difference between Add, Attach and Update, and why is that dangerous with disconnected graphs?#
Short answer: All three start tracking an entity graph, but they assign different EntityState values to it: Add marks the whole graph Added (every entity gets inserted), Attach marks the whole graph Unchanged (nothing gets written unless you later modify a property), and Update marks the whole graph Modified — which means every scalar property on every entity in that graph will be included in the generated UPDATE statement, whether or not it actually changed.
This becomes a real bug in disconnected scenarios — a typical Web API PUT handler that receives a JSON payload, maps it to an entity, and calls Update because "the client sent the current values." If the client payload omits a field the server manages (an audit timestamp, a computed flag), that field gets overwritten with its CLR default, because Update doesn't know which properties actually changed on the database side — it just marks everything as changed relative to an assumed original state it never actually saw. The safer patterns are: fetch the existing entity first and apply only the changed properties (an extra round trip, but correct), or use Entry(entity).Property(x => x.Name).IsModified = true to mark exactly the fields you intend to update, or configure concurrency tokens so unintended overwrites at least fail loudly instead of silently.
var order = new Order { Id = dto.Id, Status = dto.Status };
context.Orders.Attach(order);
context.Entry(order).Property(o => o.Status).IsModified = true; // only Status is updated
await context.SaveChangesAsync();What interviewers look for: recognizing Update's whole-graph Modified behavior as a footgun in disconnected APIs, not just reciting the EntityState enum values.
- Common mistakes: calling
Updatereflexively on every incoming DTO; not realizingAddon a graph with an explicitly-set, non-default key can behave unexpectedly depending on how the key generation strategy is configured.
Quick-Fire Round#
| Question | Answer |
|---|---|
| What method performs snapshot comparison for change tracking? | ChangeTracker.DetectChanges(). |
| What NuGet package enables lazy-loading proxies? | Microsoft.EntityFrameworkCore.Proxies. |
| What CLI command generates a compiled model? | dotnet ef dbcontext optimize. |
| Which three interceptors are registered as singletons? | IMaterializationInterceptor, IQueryExpressionInterceptor, IIdentityResolutionInterceptor. |
| What exception fires when a context handles two operations concurrently? | InvalidOperationException for overlapping operations on the same instance. |
| What replaced owned types for most JSON and table-splitting modeling in EF Core 8+? | Complex types, via ComplexProperty. |
What DI lifetime does AddDbContext use by default? | Scoped. |
What EntityState does Update assign to an entire graph? | Modified. |
How to Prepare#
- Build a small solo project and deliberately reproduce each failure mode above — an N+1 loop, a disconnected
Updateoverwrite, aDetectChangesslowdown — so you can describe the symptom as well as the fix. - Read the official docs on identity resolution and interceptors closely; both areas have subtle behavior that's easy to get wrong verbally in an interview.
- Practice sketching the
SaveChangesdependency-graph ordering on a whiteboard for a three-table parent/child/grandchild graph, including a self-referencing table. - Be ready to explain the
EntityStatetransitions forAdd,AttachandUpdatewithout hesitation — this is one of the most commonly asked "gotcha" questions at the senior level. - Know when compiled models and compiled queries are worth the complexity, and be honest that for most applications they aren't.