Skip to main content
Published / updated

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 SaveChanges actually does
  • Generated keys
  • Soft delete
  • Transactions and the implicit unit of work
  • Constraint failures and error translation
  • Bulk operations: ExecuteUpdate and ExecuteDelete
  • 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);
  1. Runs change detection over every tracked entity
  2. Orders operations to respect foreign keys — parents before children on insert, the reverse on delete
  3. Opens a transaction if more than one statement is needed
  4. Batches the statements
  5. Reads back generated values — identities, defaults, computed columns
  6. Commits, or rolls back and throws
  7. 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 SaveChanges interceptors or auditing run. If your context stamps ModifiedAt in an override of SaveChanges, 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

MessageCauseFix
SaveChanges writes nothingThe entity is not tracked, or nothing changedAttach it, or check the change tracker
The instance of entity type cannot be tracked because another instance with the same key is already being trackedTwo objects with the same key in one contextUse one, or AsNoTracking on the read
The database operation was expected to affect 1 row(s) but actually affected 0Row deleted or changed by someone elseHandle the concurrency conflict
An update writes every columnThe whole entity is marked modifiedUpdate only what changed
A delete fails on a foreign keyChild rows still reference itDelete 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 Add to hit the database before SaveChanges
  • Calling DbSet.Update on a tracked entity, writing every column
  • Update on a detached entity built from partial input, overwriting fields the caller never sent
  • Loading with AsNoTracking and then expecting changes to save
  • An explicit transaction where SaveChanges was already atomic
  • A user-initiated transaction with EnableRetryOnFailure and no execution strategy
  • Matching on DbUpdateException.Message instead of the inner SqlException.Number
  • Letting DbUpdateException reach the controller
  • Looping SaveChanges per entity instead of batching
  • Mixing ExecuteUpdate with tracked entities in one operation
  • Expecting ExecuteUpdate to run SaveChanges interceptors
  • No concurrency token, so saves silently overwrite

Practice

The course exercise is build CRUD queries; this article is the write half.

  1. Create a student and confirm student.Id is populated after SaveChangesAsync.
  2. Create a student with a nested FeeAccount in one Add. Read the generated SQL and confirm the ordering and the foreign key.
  3. Load a student, change one property, save. Confirm the UPDATE sets only that column.
  4. Use DbSet.Update instead and compare the SQL.
  5. Build a detached Student from partial input, call Update, and save. Confirm the columns you did not set were overwritten with defaults.
  6. Load with AsNoTracking, modify, and save. Confirm nothing is written.
  7. Add and save three entities in one SaveChanges. Force a failure on the third and confirm none were written.
  8. Insert a duplicate roll number. Confirm DbUpdateException with inner SqlException.Number == 2627, then translate it.
  9. Promote 400 students with a tracked loop, then with ExecuteUpdateAsync. Compare statement counts and elapsed time.
  10. Load a student, run ExecuteUpdateAsync on the same row, then read the loaded entity's property. Confirm it is stale.
  11. Add a RowVersion token. Load one student into two contexts, save both, and confirm the second throws DbUpdateConcurrencyException.
  12. 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

  1. Why does modifying a loaded entity need no Update call?
  2. When is an explicit transaction necessary, given that SaveChanges is already atomic?
  3. Where is the SQL Server error number when a constraint fails?
  4. What does ExecuteUpdate skip that a tracked update does not?

Next: Relationships and Include