Overview
DbContext, DbSet, entity states, and the Change Tracker are central to how Entity Framework Core reads data, tracks object changes, and writes those changes back to the database.
In EF Core, a DbContext represents a short-lived unit of work. It usually lives for one business operation, such as one HTTP request, one command handler, one background job step, or one screen edit operation. During that unit of work, EF Core can query entities, track them, detect changes, and generate SQL commands when SaveChanges or SaveChangesAsync is called.
This topic matters because many production bugs in EF Core come from misunderstanding tracking behavior. Common issues include accidentally updating every column, attaching duplicate entity instances, using a long-lived DbContext, mixing tracked and untracked entities incorrectly, calling Update on disconnected DTOs, or disabling DetectChanges without understanding the consequences.
It is important for interviews because it tests more than basic EF Core syntax. A strong candidate should understand how EF Core works internally enough to make good persistence decisions, debug unexpected updates, design clean API update flows, and avoid performance and concurrency problems.
Typical real-world use cases include:
- Loading an entity, changing a few properties, and saving only the changed columns.
- Creating new entities with
Add. - Updating disconnected entities from API request DTOs.
- Attaching existing entities without querying them first.
- Deleting entities with
Remove. - Inspecting
ChangeTracker.Entries()for auditing, domain events, debugging, or soft-delete logic. - Choosing between tracking queries and
AsNoTrackingqueries. - Avoiding duplicate tracked entity instances in long-running workflows.
- Understanding why an entity is
Added,Modified,Deleted,Unchanged, orDetached.
Core Concepts
DbContext as a Unit of Work
DbContext is the main EF Core object used to coordinate database access. It combines several responsibilities:
- Database connection and provider configuration.
- Query execution.
- Change tracking.
- Relationship fix-up.
- Entity state management.
- Command generation.
- Transaction coordination for
SaveChanges.
A common mental model is:
- Create a
DbContext. - Query, add, attach, update, or remove entities.
- Change entity property values.
- Call
SaveChangesAsync. - Dispose the
DbContext.
Example:
public sealed class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
}
In ASP.NET Core, DbContext is commonly registered as scoped:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});
For a typical Web API request, this means the same context instance is used during one request scope and disposed at the end of that request.
Best practice:
public sealed class UpdateCustomerHandler
{
private readonly AppDbContext _dbContext;
public UpdateCustomerHandler(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task HandleAsync(int customerId, string newName, CancellationToken cancellationToken)
{
var customer = await _dbContext.Customers
.SingleAsync(c => c.Id == customerId, cancellationToken);
customer.Name = newName;
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
Avoid treating DbContext as a singleton. It is not thread-safe, and a long-lived context can accumulate tracked entities, stale state, memory usage, and identity resolution conflicts.
DbSet
DbSet<TEntity> represents a queryable and mutable set of entities of a specific type.
It is used for:
- Querying a database table or collection-like entity source.
- Adding new entities.
- Attaching existing entities.
- Updating disconnected entities.
- Removing entities.
- Accessing local tracked entities.
Example:
var activeCustomers = await dbContext.Customers
.Where(c => c.IsActive)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
A DbSet<Customer> does not literally hold every customer in memory. It represents the query root for Customer entities. The database is queried only when the LINQ query is executed, such as by calling ToListAsync, SingleAsync, FirstOrDefaultAsync, or iterating the query.
Entity States
EF Core assigns each tracked entity an EntityState.
Example:
var customer = await dbContext.Customers.FindAsync([1], cancellationToken);
Console.WriteLine(dbContext.Entry(customer!).State); // Unchanged
customer!.Name = "Updated Name";
Console.WriteLine(dbContext.Entry(customer).State); // Often still Unchanged until changes are detected
await dbContext.SaveChangesAsync(cancellationToken);
A key interview point is that EF Core can track modifications at the property level. If a tracked entity is loaded from the database and only one property changes, EF Core can generate an update for only that changed property.
Change Tracker
The Change Tracker is the EF Core component that tracks entity instances and their states.
It keeps information such as:
- Which entities are being tracked.
- Entity state.
- Original property values.
- Current property values.
- Modified properties.
- Temporary key values.
- Relationship changes.
- Navigation fix-up information.
Example:
foreach (var entry in dbContext.ChangeTracker.Entries())
{
Console.WriteLine($"{entry.Entity.GetType().Name}: {entry.State}");
}
The Change Tracker is what allows this simple update pattern to work:
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == customerId, cancellationToken);
customer.Email = request.Email;
await dbContext.SaveChangesAsync(cancellationToken);
EF Core knows customer was loaded and tracked. It compares the current values with the original values and generates the needed database update.
Snapshot Change Tracking
By default, EF Core uses snapshot change tracking.
When an entity is first tracked, EF Core stores a snapshot of its property values. Later, EF Core compares the current values with that snapshot to detect what changed.
Example:
var product = await dbContext.Products
.SingleAsync(p => p.Id == productId, cancellationToken);
product.Price = 99.99m;
await dbContext.SaveChangesAsync(cancellationToken);
EF Core originally remembers the old Price. At save time, it detects that Price changed and generates an update.
Conceptually:
Original Price: 79.99
Current Price: 99.99
State: Modified
Modified prop: Price
This is different from simply saying "the entity object changed." EF Core needs either tracked state, explicit state changes, or attached state to know what should be written.
DetectChanges
DetectChanges is the process EF Core uses to discover changes made to tracked entities.
It can be triggered automatically by EF Core in common operations such as:
SaveChanges.SaveChangesAsync.ChangeTracker.Entries().ChangeTracker.HasChanges().DbSet.Local.- Some
Entryoperations.
You can also call it manually:
dbContext.ChangeTracker.DetectChanges();
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);
Manual DetectChanges is useful when:
- Debugging tracking state.
- Working with low-level change-tracking APIs.
- Temporarily disabling automatic change detection for performance.
- Inspecting
DebugView.
A common performance pattern for large imports is to temporarily disable automatic change detection:
dbContext.ChangeTracker.AutoDetectChangesEnabled = false;
try
{
foreach (var item in importedItems)
{
dbContext.Products.Add(item);
}
dbContext.ChangeTracker.DetectChanges();
await dbContext.SaveChangesAsync(cancellationToken);
}
finally
{
dbContext.ChangeTracker.AutoDetectChangesEnabled = true;
}
This should be used carefully. Disabling automatic change detection can improve performance in some large batch scenarios, but it can also cause incorrect behavior if the code expects EF Core to detect changes automatically.
Tracking Queries
By default, EF Core queries that return entity types are tracking queries.
Example:
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == customerId, cancellationToken);
customer.Name = "New Name";
await dbContext.SaveChangesAsync(cancellationToken);
The loaded customer is tracked. Updating the property and calling SaveChangesAsync is enough.
Tracking queries are useful when:
- You intend to update the returned entities.
- You need identity resolution within the context.
- You want EF Core to preserve original values for efficient updates.
- You are working inside one unit of work.
No-Tracking Queries
AsNoTracking tells EF Core not to track returned entities.
Example:
var customers = await dbContext.Customers
.AsNoTracking()
.Where(c => c.IsActive)
.ToListAsync(cancellationToken);
No-tracking queries are usually better for read-only operations because they avoid the overhead of tracking.
Use AsNoTracking for:
- Read-only API responses.
- Reports.
- Dropdown lists.
- Search results.
- Queries with many rows where no update is needed.
Do not use AsNoTracking if you plan to edit the returned entity and expect SaveChanges to detect it automatically.
Problem example:
var customer = await dbContext.Customers
.AsNoTracking()
.SingleAsync(c => c.Id == customerId, cancellationToken);
customer.Name = "Updated Name";
await dbContext.SaveChangesAsync(cancellationToken); // No update, because customer is not tracked
To update an untracked entity, you must either query a tracked entity first or explicitly attach/update it.
Identity Resolution
A single DbContext can track only one entity instance with a given primary key value.
This means the following pattern can fail:
var trackedCustomer = await dbContext.Customers
.SingleAsync(c => c.Id == request.Id, cancellationToken);
var detachedCustomer = new Customer
{
Id = request.Id,
Name = request.Name
};
dbContext.Update(detachedCustomer); // Can throw because another instance with same key is already tracked
EF Core avoids tracking multiple instances with the same key because it would not know which instance represents the correct state.
Better approach:
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == request.Id, cancellationToken);
customer.Name = request.Name;
customer.Email = request.Email;
await dbContext.SaveChangesAsync(cancellationToken);
Identity resolution is one reason short-lived contexts are important. A long-lived context increases the chance that stale or duplicate entity instances remain tracked.
Add
Add tells EF Core that an entity is new and should be inserted.
var customer = new Customer
{
Name = "Alice",
Email = "[email protected]"
};
dbContext.Customers.Add(customer);
await dbContext.SaveChangesAsync(cancellationToken);
The entity state becomes Added. On save, EF Core generates an INSERT.
Important behavior:
Addcan affect an entire object graph.- Entities with generated keys may receive temporary key values before save.
- After save, generated database keys are populated back into the entity.
- After successful save, the entity usually becomes
Unchanged.
Attach
Attach starts tracking an existing entity without marking it as modified.
var customer = new Customer
{
Id = 10
};
dbContext.Customers.Attach(customer);
Console.WriteLine(dbContext.Entry(customer).State); // Unchanged
Attach is useful when:
- You know the entity already exists.
- You do not want to update every column.
- You want to set a relationship using a known key.
- You want to mark specific properties as modified manually.
Example: update one property without loading the full row:
var customer = new Customer
{
Id = request.Id
};
dbContext.Customers.Attach(customer);
customer.Email = request.Email;
dbContext.Entry(customer)
.Property(c => c.Email)
.IsModified = true;
await dbContext.SaveChangesAsync(cancellationToken);
This can be efficient, but it must be used carefully because:
- You bypass database validation that a prior query might have provided.
- You may not have original values for concurrency checks unless configured separately.
- You must explicitly mark what changed.
- Incorrect use can cause missing updates.
Update
Update starts tracking an entity as Modified.
var customer = new Customer
{
Id = request.Id,
Name = request.Name,
Email = request.Email
};
dbContext.Customers.Update(customer);
await dbContext.SaveChangesAsync(cancellationToken);
This is simple, but it can be dangerous with disconnected API models.
Update usually marks the entire entity graph as modified. That can result in updating more columns than intended.
Risky pattern:
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateCustomer(int id, Customer customer)
{
if (id != customer.Id)
{
return BadRequest();
}
_dbContext.Customers.Update(customer);
await _dbContext.SaveChangesAsync();
return NoContent();
}
Problems:
- The API accepts an entity directly instead of a DTO.
- The client might omit properties.
- Omitted properties may overwrite database values.
- More columns may be updated than necessary.
- Related entities in the graph may also be marked modified.
- It can bypass business rules.
Safer pattern:
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateCustomer(int id, UpdateCustomerRequest request, CancellationToken cancellationToken)
{
var customer = await _dbContext.Customers
.SingleOrDefaultAsync(c => c.Id == id, cancellationToken);
if (customer is null)
{
return NotFound();
}
customer.Name = request.Name;
customer.Email = request.Email;
await _dbContext.SaveChangesAsync(cancellationToken);
return NoContent();
}
This pattern allows EF Core to track only actual changes and keeps update logic explicit.
Remove
Remove marks an entity as Deleted.
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == customerId, cancellationToken);
dbContext.Customers.Remove(customer);
await dbContext.SaveChangesAsync(cancellationToken);
If the entity is not tracked, Remove first attaches it and then marks it as deleted:
var customer = new Customer { Id = customerId };
dbContext.Customers.Remove(customer);
await dbContext.SaveChangesAsync(cancellationToken);
This can be useful for deleting by key without loading the entity, but it should be used carefully when business rules require checking the current database state.
Attach vs Update
Attach and Update are common interview comparison points.
Example:
var customer = new Customer { Id = 1, Name = "Alice" };
dbContext.Attach(customer);
// State: Unchanged
dbContext.Update(customer);
// State: Modified
Use Attach when you want more control. Use Update only when you are comfortable treating the supplied entity or graph as the full updated state.
Graph Tracking Behavior
Add, Attach, and Update can apply to an entire graph of related entities.
Example:
var order = new Order
{
Id = 10,
CustomerId = 1,
Lines =
{
new OrderLine { Id = 100, ProductId = 5, Quantity = 2 },
new OrderLine { ProductId = 6, Quantity = 1 }
}
};
dbContext.Orders.Update(order);
Depending on key values and configuration, EF Core may treat some related entities as existing and others as new. With generated keys, an unset key value often indicates a new entity.
This is useful, but it can be risky for API update endpoints. A client-provided graph might unintentionally insert, update, or delete related records.
For complex aggregate updates, a safer approach is often:
- Load the aggregate from the database.
- Apply the command or DTO intentionally.
- Add, update, or remove child entities according to business rules.
- Call
SaveChangesAsync.
Generated Keys and Temporary Keys
For entities with generated keys, EF Core may assign temporary key values before the database generates the real values.
Example:
var customer = new Customer
{
Name = "Alice"
};
dbContext.Customers.Add(customer);
Console.WriteLine(customer.Id); // May be temporary internally before save
await dbContext.SaveChangesAsync(cancellationToken);
Console.WriteLine(customer.Id); // Real database-generated ID
In disconnected graph scenarios, generated keys help EF Core distinguish new entities from existing entities. If a generated key has its default value, EF Core can infer that the entity is new.
Property-Level Updates
EF Core can update only selected properties when it knows exactly what changed.
Tracked entity pattern:
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == request.Id, cancellationToken);
customer.Name = request.Name;
await dbContext.SaveChangesAsync(cancellationToken);
Only changed properties are marked modified.
Manual property update pattern:
var customer = new Customer { Id = request.Id };
dbContext.Attach(customer);
customer.Name = request.Name;
dbContext.Entry(customer)
.Property(c => c.Name)
.IsModified = true;
await dbContext.SaveChangesAsync(cancellationToken);
This avoids a preliminary query but requires careful handling of validation, authorization, concurrency, and missing data.
CurrentValues and OriginalValues
Each tracked entity has an EntityEntry that exposes current and original values.
Example:
var entry = dbContext.Entry(customer);
var currentName = entry.CurrentValues[nameof(Customer.Name)];
var originalName = entry.OriginalValues[nameof(Customer.Name)];
A useful DTO update pattern is SetValues:
var customer = await dbContext.Customers
.SingleAsync(c => c.Id == request.Id, cancellationToken);
dbContext.Entry(customer).CurrentValues.SetValues(request);
await dbContext.SaveChangesAsync(cancellationToken);
This works best when the DTO property names match entity property names. However, many teams prefer explicit assignment to avoid accidental updates to fields that should not be client-controlled.
Debugging Change Tracker State
ChangeTracker.DebugView can help explain what EF Core will save.
dbContext.ChangeTracker.DetectChanges();
Console.WriteLine(dbContext.ChangeTracker.DebugView.LongView);
This is useful when debugging:
- Why an update did not happen.
- Why an unexpected insert happened.
- Why too many columns are updated.
- Why an entity is still
Unchanged. - Why a duplicate tracking exception occurs.
- Which properties are marked as modified.
You can also inspect entries:
var entries = dbContext.ChangeTracker.Entries()
.Select(e => new
{
Entity = e.Entity.GetType().Name,
State = e.State
})
.ToList();
Tracking vs No-Tracking Performance
Tracking has overhead because EF Core must store snapshots and manage entity state.
Use tracking when:
- You intend to modify entities.
- You need EF Core to detect changes.
- You need identity resolution inside the context.
- You are working in a command/update flow.
Use no-tracking when:
- The result is read-only.
- The result is projected to a DTO.
- The query is used for reports or search.
- The result set is large and does not need to be updated.
Example projection:
var customers = await dbContext.Customers
.AsNoTracking()
.Select(c => new CustomerListItemDto
{
Id = c.Id,
Name = c.Name,
Email = c.Email
})
.ToListAsync(cancellationToken);
For many read APIs, projection plus AsNoTracking is a good default.
Disconnected Entities in Web APIs
Web APIs commonly receive DTOs from clients. These DTOs are disconnected from the DbContext.
Example request:
public sealed record UpdateProductRequest(
string Name,
decimal Price);
Recommended update flow:
public async Task UpdateProductAsync(
int productId,
UpdateProductRequest request,
CancellationToken cancellationToken)
{
var product = await dbContext.Products
.SingleOrDefaultAsync(p => p.Id == productId, cancellationToken);
if (product is null)
{
throw new KeyNotFoundException("Product was not found.");
}
product.Name = request.Name;
product.Price = request.Price;
await dbContext.SaveChangesAsync(cancellationToken);
}
This approach is clear, safe, and easy to validate.
Alternative disconnected update:
var product = new Product
{
Id = productId,
Name = request.Name,
Price = request.Price
};
dbContext.Products.Update(product);
await dbContext.SaveChangesAsync(cancellationToken);
This is shorter but less precise. It can be acceptable for internal tools or simple full-replacement commands, but it is risky for public APIs where partial data, authorization, and overposting matter.
Overposting Risk
Overposting happens when a client can update fields that should not be client-controlled.
Risky entity binding:
public sealed class User
{
public int Id { get; set; }
public string DisplayName { get; set; } = "";
public bool IsAdmin { get; set; }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateUser(int id, User user)
{
_dbContext.Users.Update(user);
await _dbContext.SaveChangesAsync();
return NoContent();
}
A malicious client could set IsAdmin = true.
Safer DTO:
public sealed record UpdateUserRequest(string DisplayName);
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateUser(
int id,
UpdateUserRequest request,
CancellationToken cancellationToken)
{
var user = await _dbContext.Users
.SingleOrDefaultAsync(u => u.Id == id, cancellationToken);
if (user is null)
{
return NotFound();
}
user.DisplayName = request.DisplayName;
await _dbContext.SaveChangesAsync(cancellationToken);
return NoContent();
}
DTOs define the request contract and prevent accidental persistence of fields that should not be writable.
Change Tracker and Relationships
EF Core also tracks relationship changes.
Example:
var order = await dbContext.Orders
.Include(o => o.Lines)
.SingleAsync(o => o.Id == orderId, cancellationToken);
order.Lines.Add(new OrderLine
{
ProductId = productId,
Quantity = 2
});
await dbContext.SaveChangesAsync(cancellationToken);
EF Core detects the new child entity and inserts it.
Changing a reference can also update a foreign key:
order.CustomerId = newCustomerId;
await dbContext.SaveChangesAsync(cancellationToken);
Relationship fix-up means EF Core can synchronize foreign key values and navigation properties for tracked entities. This is helpful, but it can be confusing when many entities are tracked in a long-lived context.
SaveChanges Behavior
SaveChanges and SaveChangesAsync are the point where tracked changes are converted into database commands.
Conceptually, EF Core does the following:
- Detect changes.
- Determine entity states and modified properties.
- Generate insert, update, and delete commands.
- Execute commands.
- Accept changes if save succeeds.
- Mark saved entities as
Unchanged.
Example:
dbContext.Customers.Add(new Customer { Name = "Alice" });
var existing = await dbContext.Products
.SingleAsync(p => p.Id == 5, cancellationToken);
existing.Price = 100m;
await dbContext.SaveChangesAsync(cancellationToken);
The same SaveChangesAsync call can insert the new customer and update the product.
DbContext Is Not Thread-Safe
A single DbContext instance should not be used concurrently from multiple threads.
Bad pattern:
await Task.WhenAll(
ProcessCustomerAsync(dbContext, 1),
ProcessCustomerAsync(dbContext, 2));
Better pattern:
await Task.WhenAll(
ProcessCustomerWithNewContextAsync(1),
ProcessCustomerWithNewContextAsync(2));
For background jobs or parallel operations, use separate scopes or an IDbContextFactory<TContext>.
Example:
public sealed class ProductWorker
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public ProductWorker(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
public async Task ProcessAsync(int productId, CancellationToken cancellationToken)
{
await using var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken);
var product = await dbContext.Products
.SingleAsync(p => p.Id == productId, cancellationToken);
product.LastProcessedUtc = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
}
}
Common Mistakes
Common mistakes include:
- Using a singleton
DbContext. - Sharing one
DbContextacross parallel tasks. - Calling
Updateon a client-provided entity without checking overposting risk. - Mixing a tracked entity and a detached entity with the same key.
- Using
AsNoTrackingand expecting automatic updates. - Attaching an entity already tracked by the same context.
- Forgetting that
Updatecan mark an entire graph as modified. - Disabling
AutoDetectChangesEnabledand forgetting to re-enable it. - Keeping a context alive too long and seeing stale data.
- Exposing EF Core entities directly as API request models.
- Calling
SaveChangesinside every repository method instead of coordinating one unit of work. - Not passing
CancellationTokento async EF Core operations. - Ignoring concurrency control when updating disconnected entities.
Best Practices
Good EF Core tracking habits:
- Use short-lived
DbContextinstances. - Let dependency injection manage scoped contexts in typical ASP.NET Core requests.
- Use tracking queries for command/update flows.
- Use
AsNoTrackingand DTO projections for read-only queries. - Prefer DTOs over binding API requests directly to EF Core entities.
- Prefer query-then-update for business-critical updates.
- Use
Attachplus property-levelIsModifiedonly when you intentionally want a partial update without loading. - Use
Updatecarefully for full replacement scenarios. - Inspect
ChangeTracker.DebugView.LongViewwhen behavior is confusing. - Avoid parallel operations on the same context.
- Keep transaction boundaries and
SaveChangesboundaries clear. - Understand graph behavior before using
Add,Attach, orUpdateon aggregate roots. - Use concurrency tokens when multiple users or processes may update the same row.
- Keep persistence logic explicit enough that future maintainers can see what is being changed.