blog post

Understanding Change Tracking for Better Performance in EF Core

Change Tracker is the heart of EF Core, that keeps an eye on entities that are added, updated and deleted. In today's post you will learn how Change Tracker works, how entities are tracked, and how to attach existing entities to the Change Tracker. You will receive guidelines on how to improve your application's performance with tracking techniques.

In the end, we will explore how EF Core Change Tracker can significantly improve our code in the read-world scenario.

What is Change Tracker in EF Core

The Change Tracker is a key part of EF Core responsible for keeping track of entity instances and their states. It monitors changes to these instances and ensures the database is updated accordingly. This tracking mechanism is essential for EF Core to know which entities must be inserted, updated, or deleted in the database.

When you query the database, EF Core automatically starts tracking the returned entities.

csharp
using (var dbContext = new ApplicationDbContext()) { var users = await dbContext.Users.ToListAsync(); }

After querying users from the database, all entities are automatically added to the Change Tracker. When updating the users - change tracker will compare the users collection with its inner collection of User entities that were retrieved from the database. EF Core will use the comparison result to decide what SQL commands to generate to update entities in the database.

csharp
using (var dbContext = new ApplicationDbContext()) { var users = await dbContext.Users.ToListAsync(); users[0].Email = "[email protected]"; await dbContext.SaveChangesAsync(); }

In this example, we are updating the first user's email. After calling dbContext.SaveChangesAsync() EF Core compares users collection with the one saved in the Change Tracker. After comparing, EF Core finds out that users collection was updated and the update SQL query is sent to the database:

sql
Executed DbCommand (0ms) [Parameters=[@p1='****', @p0='[email protected]' (Nullable = false) (Size = 13)], CommandType='Text', CommandTimeout='30'] UPDATE "users" SET "email" = @p0 WHERE "id" = @p1 RETURNING 1;

To add and delete entities you should call the Add and Remove methods:

csharp
using (var dbContext = new ApplicationDbContext()) { var users = await dbContext.Users.ToListAsync(); dbContext.Users.Remove(users[1]); dbContext.Users.Add(new User { Id = Guid.NewGuid(), Email = "[email protected]" }); await dbContext.SaveChangesAsync(); }

Change Tracker will detect that a second user is deleted and a new user is added. As a result, the following SQL commands will be sent to the database to delete and create a user:

sql
Executed DbCommand (0ms) [Parameters=[@p0='***'], CommandType='Text', CommandTimeout='30'] DELETE FROM "users" WHERE "id" = @p0 RETURNING 1; Executed DbCommand (0ms) [Parameters=[@p0='***', @p1='[email protected]' (Nullable = false) (Size = 12)], CommandType='Text', CommandTimeout='30'] INSERT INTO "users" ("id", "email") VALUES (@p0, @p1);

Change Tracker and Child Entities

Change Tracker in EF Core also tracks child entities that are loaded together with other entities. Let's explore the following entities:

csharp
public class Book { public required Guid Id { get; set; } public required string Title { get; set; } public required int Year { get; set; } public Guid AuthorId { get; set; } public Author Author { get; set; } = null!; } public class Author { public required Guid Id { get; set; } public required string Name { get; set; } public List<Book> Books { get; set; } = []; }

A Book is mapped as one-to-many to the Author.

When executing the following code and updating the first book's author name:

csharp
using (var dbContext = new ApplicationDbContext()) { var books = await dbContext.Books .Include(x => x.Author) .ToListAsync(); books[0].Author.Name = "Jack Sparrow"; await dbContext.SaveChangesAsync(); }

EF Core generates an update request to the database:

sql
Executed DbCommand (0ms) [Parameters=[@p1='***', @p0='Jack Sparrow' (Nullable = false) (Size = 12)], CommandType='Text', CommandTimeout='30'] UPDATE "authors" SET "name" = @p0 WHERE "id" = @p1 RETURNING 1;

Now let's try to add a new book to the first author:

csharp
using (var dbContext = new ApplicationDbContext()) { var authors = await dbContext.Authors .Include(x => x.Books) .ToListAsync(); var newBook = new Book { Id = Guid.NewGuid(), Title = "Asp.Net Core In Action", Year = 2024 }; authors[0].Books.Add(newBook); dbContext.Entry(newBook).State = EntityState.Added; await dbContext.SaveChangesAsync(); }

In this case, you need to manually notify Change Tracker that book was added to the author:

csharp
dbContext.Entry(newBook).State = EntityState.Added;

As a result, an insert query with a foreign key to Author will be sent to the database:

sql
Executed DbCommand (11ms) [Parameters=[@p0='fba984cd-a7b8-4eee-998b-165db95068a5', @p1='1072efd7-a71f-40a5-a939-5e68b7e34e0c', @p2='Asp.Net Core In Action' (Nullable = false) (Size = 22), @p3='2024'], CommandType='Text', CommandTimeout='30'] INSERT INTO "books" ("id", "author_id", "title", "year") VALUES (@p0, @p1, @p2, @p3);

How Entities are Tracked In EF Core

Entities in EF Core are tracked based on their state, which can be one of the following:

  • Added - the entity is new and will be inserted into the database.
  • Modified - the entity has been modified and will be updated in the database
  • Deleted - the entity has been marked for deletion
  • Detached - the entity should not be tracked and will be removed from the change tracker
  • Unchanged - the entity has not been modified since it was loaded

You can check the state of an entity using the Entry property of the DbContext:

csharp
using (var dbContext = new ApplicationDbContext()) { var book = dbContext.Books.First(); var entry = dbContext.Entry(book); var state = entry.State; // EntityState.Unchanged }

Attaching Existing Entities to the Change Tracker

As you've already seen, sometimes, you might need to attach an existing entity to the Change Tracker. This is common in scenarios where entities are retrieved from a different context or from outside the database (e.g., from an API).

To attach an entity, you can use the Attach method so the Change Tracker will start tracking this entity. This method marks the entity as Unchanged by default.

You need to specify whether this entity should be either modified or deleted in the database:

csharp
using (var dbContext = new ApplicationDbContext()) { var book = new Book { Id = Guid.NewGuid(), Title = "Asp.Net Core In Action", Year = 2024 }; dbContext.Books.Attach(book); dbContext.Entry(book).State = EntityState.Modified; dbContext.Books.Attach(book); dbContext.Entry(book).State = EntityState.Deleted; }

Batch Tracking Operations in EF Core

EF Core provides range operations to perform batch operations on multiple entities. These methods can simplify code and improve performance.

AddRange

Adds a collection of new entities to the context:

csharp
using (var dbContext = new ApplicationDbContext()) { var author = new Author { Id = Guid.NewGuid(), Name = "Andrew Lock" }; var books = new List<Book> { new() { Id = Guid.NewGuid(), Title = "Asp.Net Core In Action 2.0", Year = 2020, Author = author }, new() { Id = Guid.NewGuid(), Title = "Asp.Net Core In Action 3.0", Year = 2024, Author = author } }; dbContext.Books.AddRange(books); await dbContext.SaveChangesAsync(); }

UpdateRange

Updates a collection of entities in the context:

csharp
using (var dbContext = new ApplicationDbContext()) { var booksToUpdate = await dbContext.Books .Where(x => x.Year >= 2020) .ToListAsync(); booksToUpdate.ForEach(b => b.Title += "-updated"); dbContext.Books.UpdateRange(booksToUpdate); await dbContext.SaveChangesAsync(); }

RemoveRange

Removes a collection of entities from the context:

csharp
using (var dbContext = new ApplicationDbContext()) { var blogsToDelete = await dbContext.Books .Where(x => x.Year < 2020) .ToListAsync(); dbContext.Books.RemoveRange(blogsToDelete); await dbContext.SaveChangesAsync(); }

AttachRange

Attaches a collection of existing entities to the context:

csharp
using (var dbContext = new ApplicationDbContext()) { var books = new List<Book> { // ... }; dbContext.Books.AttachRange(books); foreach (var book in books) { dbContext.Entry(book).State = EntityState.Modified; } }

How to Disable Change Tracker

When you read entities from the database, and you don't need to update them, you can inform EF Core to not track these entities in the Change Tracker. It is especially useful when you are retrieving a lot of records from the database and don't want to waste memory for tracking these entities as they won't be modified.

The AsNoTracking method is used to query entities without tracking them. This can improve performance for read-only operations, as EF Core skips the overhead of tracking changes:

csharp
using (var dbContext = new ApplicationDbContext()) { var books = await dbContext.Books .Include(x => x.Author) .AsNoTracking() .ToListAsync(); }

It's a small performance tip for optimizing read-only queries in EF Core and you need to know it.

How To Access Tracking Entities in EF Core

EF Core allows you to access and manipulate tracked entities in the Change Tracker of the current DbContext. You can retrieve all tracked entities using the Entries method:

csharp
using (var dbContext = new ApplicationDbContext()) { var books = await dbContext.Books .Include(x => x.Author) .ToListAsync(); var trackedEntities = dbContext.ChangeTracker.Entries(); foreach (var entry in trackedEntities) { Console.WriteLine($"Entity: {entry.Entity}, State: {entry.State}"); } }

You can also filter entities by their state:

csharp
using (var dbContext = new ApplicationDbContext()) { var books = await dbContext.Books .Include(x => x.Author) .ToListAsync(); books[0].Author.Name = "Jack Sparrow"; var modifiedEntities = dbContext.ChangeTracker.Entries() .Where(e => e.State == EntityState.Modified); foreach (var entry in modifiedEntities) { Console.WriteLine($"Modified Entity: {entry.Entity}"); } }

A Real-World Example of Using Change Tracker

Let's explore a real world example on how using a Change Tracker can significantly simplify our code. Imagine that you have entities that have CreatedAtUtc and UpdatedAtUtc properties. These properties are used for time audit.

CreatedAtUtc should be assigned with current UTC time when a new entity is added to the database.

UpdatedAtUtc should be assigned with current UTC time whenever an existing entity is updated in the database.

Let's explore the most basic implementation for a User entity:

csharp
public class User { public Guid Id { get; set; } public required string Email { get; set; } public DateTime CreatedAtUtc { get; set; } public DateTime? UpdatedAtUtc { get; set; } }

When creating a new user or updating an existing one, you need to manually specify these values:

csharp
using (var dbContext = new ApplicationDbContext()) { var user = new User { Id = Guid.NewGuid(), Email = "[email protected]", CreatedAtUtc = DateTime.UtcNow }; dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); user.Email = "[email protected]"; user.UpdatedAtUtc = DateTime.UtcNow; await dbContext.SaveChangesAsync(); }

It might seem that this is not a big deal, but imagine you have a more complex application where you can update not only a user's email, but his password, personal data and permissions. And you can have a lot of entities that should have CreatedAtUtc and UpdatedAtUtc properties.

Using manual approach will clutter your code, you will have code duplications here and there. Moreover, you can forget to set these properties and introduce a bug in your code.

What if I tell you that you can use Change Tracker in EF Core to set these properties automatically in one place for all entities that should have time audit?

First, let's introduce an interface:

csharp
public interface ITimeAuditableEntity { DateTime CreatedAtUtc { get; set; } DateTime? UpdatedAtUtc { get; set; } }

All entities that need time audit should inherit from this interface:

csharp
public class Book : ITimeAuditableEntity { // Other properties public DateTime CreatedAtUtc { get; set; } public DateTime? UpdatedAtUtc { get; set; } } public class Author : ITimeAuditableEntity { // Other properties public DateTime CreatedAtUtc { get; set; } public DateTime? UpdatedAtUtc { get; set; } }

Now in the DbContext you can override the SaveChangesAsync method to automatically set the CreatedAtUtc and UpdatedAtUtc properties:

csharp
public class ApplicationDbContext : DbContext { public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) { var entries = ChangeTracker.Entries<ITimeAuditableEntity>(); foreach (var entry in entries) { if (entry.State is EntityState.Added) { entry.Entity.CreatedAtUtc = DateTime.UtcNow; } else if (entry.State is EntityState.Modified) { entry.Entity.UpdatedAtUtc = DateTime.UtcNow; } } return await base.SaveChangesAsync(cancellationToken); } }

By using ChangeTracker.Entries<ITimeAuditableEntity>(); you can receive filtered tracked entities. After that, CreatedAtUtc and UpdatedAtUtc properties are set for entities that are added and updated. Finally, we are calling base.SaveChangesAsync method to save changes to the database.

If you have multiple DbContexts in your application, you can use an EF Core Interceptor to achieve the same goal. This way you won't need to duplicate the code across all DbContexts.

Here is how to create such an Interceptor:

csharp
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; public class TimeAuditableInterceptor : SaveChangesInterceptor { public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { var context = eventData.Context!; var entries = context.ChangeTracker.Entries<ITimeAuditableEntity>(); foreach (var entry in entries) { if (entry.State == EntityState.Added) { entry.Entity.CreatedAtUtc = DateTime.UtcNow; } else if (entry.State == EntityState.Modified) { entry.Entity.UpdatedAtUtc = DateTime.UtcNow; } } return await base.SavingChangesAsync(eventData, result, cancellationToken); } }

And register the interceptor in the DbContext:

csharp
builder.Services.AddDbContextFactory<ApplicationDbContext>(options => { options.EnableSensitiveDataLogging().UseSqlite(connectionString); options.AddInterceptors(new TimeAuditableInterceptor()); });

You can register this interceptor for multiple DbContexts and reuse the single code base for performing time audit for any number of entities.

Hope you find this blog post useful. Happy coding!

You can download source code for this blog post for free

Improve Your .NET and Architecture Skills

Join my community of 1800+ developers and architects.

Each week you will get 2 practical tips with best practises and architecture advice.