CRUD Operations and SaveChanges
Before you start
You need: querying (Article 04).
Time: about 45 minutes, plus the practice.
Learning objective
Write create, update and delete operations that produce the SQL you expect, and handle failures at the right layer.
Topics
Add,Update,Remove- What
SaveChangesactually does - Generated keys
- Soft delete
- Transactions and the implicit unit of work
- Constraint failures and error translation
- Bulk operations:
ExecuteUpdateandExecuteDelete - Concurrency
Create
public async Task<Guid> CreateStudentAsync(Student student, CancellationToken ct)
{
student.PublicId = Guid.NewGuid();
student.Status = StudentStatus.Active;
_context.Students.Add(student);
await _context.SaveChangesAsync(ct);
return student.PublicId;
}
Add marks the entity as Added in the change tracker. Nothing reaches the database until SaveChangesAsync.
The entity is updated in place. After SaveChanges, student.Id holds the generated identity value — EF Core reads it back and assigns it. There is no separate call to fetch it.
Several at once:
_context.Students.AddRange(students);
await _context.SaveChangesAsync(ct);
EF Core batches these into a small number of statements, not one round trip per row. The batch size is provider-tuned and configurable via MaxBatchSize.
Adding a graph
Student student = new Student
{
SchoolId = schoolId,
Name = "Sneha Patel",
RollNumber = "NCA-2024-0044",
ClassName = "9th",
Section = "A",
FeeAccounts = new List<FeeAccount>
{
new FeeAccount
{
SchoolId = schoolId,
AcademicYear = "2024-25",
TotalFees = 50000m,
DueDate = DateTime.UtcNow.AddMonths(1)
}
}
};
_context.Students.Add(student);
await _context.SaveChangesAsync(ct);
Adding the parent adds every untracked child reachable from it, in the correct order, with foreign keys populated from the generated parent key. This is the single largest convenience EF Core offers over Dapper.
Update
public async Task UpdateStudentAsync(
int schoolId, Guid publicId, StudentInput input, CancellationToken ct)
{
Student? student = await _context.Students
.FirstOrDefaultAsync(s => s.SchoolId == schoolId && s.PublicId == publicId, ct);
if (student == null)
{
throw new NotFoundException("Student not found.");
}
student.Name = input.Name;
student.RollNumber = input.RollNumber;
student.ClassName = input.ClassName;
student.Section = input.Section;
student.ParentPhone = input.ParentPhone;
await _context.SaveChangesAsync(ct);
}
No Update call. The entity is tracked from the moment it was loaded, so EF Core detects the changed properties and writes only those columns:
UPDATE [Student] SET [Name] = @p0, [ClassName] = @p1
WHERE [Id] = @p2;
That is the change-tracking payoff — and the reason AsNoTracking on a query means later edits save nothing.
DbSet.Update
_context.Students.Update(student);
Marks every property as modified, producing an UPDATE that sets all columns. Use it only for a detached entity — one that came from an API request rather than from a query — and be aware it overwrites columns the caller never sent.
The load-then-modify pattern is safer, because a property the input does not carry is left alone.
Delete
Student? student = await _context.Students
.FirstOrDefaultAsync(s => s.SchoolId == schoolId && s.PublicId == publicId, ct);
if (student != null)
{
_context.Students.Remove(student);
await _context.SaveChangesAsync(ct);
}
Two round trips: one to load, one to delete. ExecuteDeleteAsync, below, does it in one.
Prefer soft delete
public async Task DeactivateStudentAsync(int schoolId, Guid publicId, CancellationToken ct)
{
Student? student = await _context.Students
.FirstOrDefaultAsync(s => s.SchoolId == schoolId && s.PublicId == publicId, ct);
if (student == null)
{
throw new NotFoundException("Student not found.");
}
student.Status = StudentStatus.Inactive;
await _context.SaveChangesAsync(ct);
}
A student has exam results, attendance, fee accounts and payments. A hard delete is either rejected by the foreign keys — correctly — or, with DeleteBehavior.Cascade, destroys records the school must retain.
The obligation soft delete creates: every query must exclude inactive rows. A global query filter does it once:
builder.HasQueryFilter(s => s.Status != StudentStatus.Inactive);
What SaveChanges does
await _context.SaveChangesAsync(ct);
- Runs change detection over every tracked entity
- Orders operations to respect foreign keys — parents before children on insert, the reverse on delete
- Opens a transaction if more than one statement is needed
- Batches the statements
- Reads back generated values — identities, defaults, computed columns
- Commits, or rolls back and throws
- Marks the surviving entities
Unchanged
SaveChanges is a transaction. Everything written in one call succeeds or fails together, with no explicit transaction needed:
_context.Students.Add(student);
_context.FeeAccounts.Add(feeAccount);
_context.AuditLogs.Add(auditLog);
await _context.SaveChangesAsync(ct); // all three, atomically
An explicit transaction is only needed to span several SaveChanges calls, or to include raw SQL:
using (IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync(ct))
{
try
{
_context.Students.Add(student);
await _context.SaveChangesAsync(ct);
feeAccount.StudentId = student.Id; // needs the generated key
_context.FeeAccounts.Add(feeAccount);
await _context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
With EnableRetryOnFailure configured, a user-initiated transaction must go through an execution strategy:
IExecutionStrategy strategy = _context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using (IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync(ct))
{
// ...
await transaction.CommitAsync(ct);
}
});
EF Core throws a clear error if you forget, telling you exactly this.
Handling constraint failures
public async Task<Guid> CreateStudentAsync(Student student, CancellationToken ct)
{
bool exists = await _context.Students.AnyAsync(
s => s.SchoolId == student.SchoolId && s.RollNumber == student.RollNumber, ct);
if (exists)
{
throw new DuplicateRollNumberException(student.RollNumber);
}
_context.Students.Add(student);
try
{
await _context.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException sql
&& (sql.Number == 2627 || sql.Number == 2601))
{
throw new DuplicateRollNumberException(student.RollNumber, ex);
}
return student.PublicId;
}
EF Core wraps provider errors in DbUpdateException. The SQL Server error number is on the inner exception — checking DbUpdateException.Message for text is fragile and breaks with localisation.
Both checks are needed. The AnyAsync check gives a clean message in the normal case; the catch handles two users submitting simultaneously, which no application-level check can prevent.
Translate to a domain exception at this boundary. A DbUpdateException reaching a controller means the controller must know EF Core and SQL Server error numbers.
Bulk operations
ExecuteUpdate and ExecuteDelete (EF Core 7 and later) issue one statement with no loading and no tracking.
// One UPDATE — no entities loaded
int affected = await _context.Students
.Where(s => s.SchoolId == schoolId && s.ClassName == "10th")
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.ClassName, "11th"), ct);
UPDATE [s] SET [s].[ClassName] = N'11th'
FROM [Student] AS [s]
WHERE [s].[SchoolId] = @__schoolId_0 AND [s].[ClassName] = N'10th'
int removed = await _context.Attendances
.Where(a => a.SchoolId == schoolId && a.Date < cutoff)
.ExecuteDeleteAsync(ct);
Compare with the tracked equivalent for 400 students: 400 rows loaded into memory, 400 entities tracked, 400 UPDATE statements batched. ExecuteUpdate is one statement.
Three things to know:
- They bypass the change tracker. Entities already loaded keep their old values in memory. Do not mix them with tracked entities in the same operation.
- They execute immediately, not on
SaveChanges— so they are outside its transaction unless you opened one explicitly. - No
SaveChangesinterceptors or auditing run. If your context stampsModifiedAtin an override ofSaveChanges, these skip it.
Use them for genuine bulk work. Use tracked updates when business logic must run per entity.
Concurrency
Two users editing the same student: without a check, the second save silently discards the first.
builder.Property(s => s.RowVersion).IsRowVersion();
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
IsRowVersion maps to SQL Server's ROWVERSION, which the database increments on every update. EF Core includes it in the WHERE clause:
UPDATE [Student] SET [Name] = @p0
WHERE [Id] = @p1 AND [RowVersion] = @p2;
Zero rows affected means someone else changed it, and EF Core throws:
try
{
await _context.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
EntityEntry entry = ex.Entries.Single();
PropertyValues? current = await entry.GetDatabaseValuesAsync(ct);
if (current == null)
{
throw new NotFoundException("This student was deleted by another user.");
}
throw new ConcurrencyException(
"This student was changed by another user. Reload and try again.");
}
GetDatabaseValuesAsync fetches the current row, which lets you tell the user what changed — or merge, when the two edits touched different fields.
Without this, last write wins and neither user is told.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
SaveChanges writes nothing | The entity is not tracked, or nothing changed | Attach it, or check the change tracker |
The instance of entity type cannot be tracked because another instance with the same key is already being tracked | Two objects with the same key in one context | Use one, or AsNoTracking on the read |
The database operation was expected to affect 1 row(s) but actually affected 0 | Row deleted or changed by someone else | Handle the concurrency conflict |
| An update writes every column | The whole entity is marked modified | Update only what changed |
| A delete fails on a foreign key | Child rows still reference it | Delete children, or use a soft delete |
SaveChanges writes what the change tracker believes changed — not what you think you changed. When nothing is written, the tracker is where to look.
Common mistakes
- Expecting
Addto hit the database beforeSaveChanges - Calling
DbSet.Updateon a tracked entity, writing every column Updateon a detached entity built from partial input, overwriting fields the caller never sent- Loading with
AsNoTrackingand then expecting changes to save - An explicit transaction where
SaveChangeswas already atomic - A user-initiated transaction with
EnableRetryOnFailureand no execution strategy - Matching on
DbUpdateException.Messageinstead of the innerSqlException.Number - Letting
DbUpdateExceptionreach the controller - Looping
SaveChangesper entity instead of batching - Mixing
ExecuteUpdatewith tracked entities in one operation - Expecting
ExecuteUpdateto runSaveChangesinterceptors - No concurrency token, so saves silently overwrite
Practice
The course exercise is build CRUD queries; this article is the write half.
- Create a student and confirm
student.Idis populated afterSaveChangesAsync. - Create a student with a nested
FeeAccountin oneAdd. Read the generated SQL and confirm the ordering and the foreign key. - Load a student, change one property, save. Confirm the
UPDATEsets only that column. - Use
DbSet.Updateinstead and compare the SQL. - Build a detached
Studentfrom partial input, callUpdate, and save. Confirm the columns you did not set were overwritten with defaults. - Load with
AsNoTracking, modify, and save. Confirm nothing is written. - Add and save three entities in one
SaveChanges. Force a failure on the third and confirm none were written. - Insert a duplicate roll number. Confirm
DbUpdateExceptionwith innerSqlException.Number == 2627, then translate it. - Promote 400 students with a tracked loop, then with
ExecuteUpdateAsync. Compare statement counts and elapsed time. - Load a student, run
ExecuteUpdateAsyncon the same row, then read the loaded entity's property. Confirm it is stale. - Add a
RowVersiontoken. Load one student into two contexts, save both, and confirm the second throwsDbUpdateConcurrencyException. - Remove the token and repeat. Confirm the first change is silently lost.
Exercises 5 and 12 are the two that cause real data loss.
You can now
- Write create, update and delete operations that generate the SQL you expect
- Say why a detached entity is not saved
- Handle a concurrency conflict rather than overwriting
- Update only the columns that changed
- Wrap several operations in one
SaveChanges
Review questions
- Why does modifying a loaded entity need no
Updatecall? - When is an explicit transaction necessary, given that
SaveChangesis already atomic? - Where is the SQL Server error number when a constraint fails?
- What does
ExecuteUpdateskip that a tracked update does not?