DEV Community

Cover image for Understanding Change Tracking for Better Performance in EF Core
Anton Martyniuk
Anton Martyniuk

Posted on • Originally published at antondevtips.com on

Understanding Change Tracking for Better Performance in EF Core

Originally published at https://antondevtips.com.

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.

using (var dbContext = new ApplicationDbContext())
{
    var users = await dbContext.Users.ToListAsync();
}
Enter fullscreen mode Exit fullscreen mode

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.

using (var dbContext = new ApplicationDbContext())
{
    var users = await dbContext.Users.ToListAsync();
    users[0].Email = "test@mail.com";

    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

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:

Executed DbCommand (0ms) [Parameters=[@p1='****', @p0='test@mail.com' (Nullable = false) (Size = 13)], CommandType='Text', CommandTimeout='30']
      UPDATE "users" SET "email" = @p0
      WHERE "id" = @p1
      RETURNING 1;
Enter fullscreen mode Exit fullscreen mode

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

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 = "one@mail.com"
    });

    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

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:

Executed DbCommand (0ms) [Parameters=[@p0='***'], CommandType='Text', CommandTimeout='30']
      DELETE FROM "users"
      WHERE "id" = @p0
      RETURNING 1;

Executed DbCommand (0ms) [Parameters=[@p0='***', @p1='one@mail.com' (Nullable = false) (Size = 12)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "users" ("id", "email")
      VALUES (@p0, @p1);
Enter fullscreen mode Exit fullscreen mode

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:

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; } = [];
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

    books[0].Author.Name = "Jack Sparrow";

    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

EF Core generates an update request to the database:

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;

Enter fullscreen mode Exit fullscreen mode

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

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();
}
Enter fullscreen mode Exit fullscreen mode

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

dbContext.Entry(newBook).State = EntityState.Added;
Enter fullscreen mode Exit fullscreen mode

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

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);
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

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();
}
Enter fullscreen mode Exit fullscreen mode

UpdateRange

Updates a collection of entities in the context:

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();
}
Enter fullscreen mode Exit fullscreen mode

RemoveRange

Removes a collection of entities from the context:

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

    dbContext.Books.RemoveRange(blogsToDelete);
    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

AttachRange

Attaches a collection of existing entities to the context:

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

You can also filter entities by their state:

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

public class User
{
    public Guid Id { get; set; }

    public required string Email { get; set; }

    public DateTime CreatedAtUtc { get; set; }

    public DateTime? UpdatedAtUtc { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

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

using (var dbContext = new ApplicationDbContext())
{
    var user = new User
    {
        Id = Guid.NewGuid(),
        Email = "test@mail.com",
        CreatedAtUtc = DateTime.UtcNow
    };

    dbContext.Users.Add(user);
    await dbContext.SaveChangesAsync();

    user.Email = "another@mail.com";
    user.UpdatedAtUtc = DateTime.UtcNow;

    await dbContext.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

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:

public interface ITimeAuditableEntity
{
    DateTime CreatedAtUtc { get; set; }

    DateTime? UpdatedAtUtc { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

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

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; }
}
Enter fullscreen mode Exit fullscreen mode

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

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

And register the interceptor in the DbContext:

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

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!

Originally published at https://antondevtips.com.

After reading the post consider the following:

  • Subscribe to receive newsletters with the latest blog posts
  • Download the source code for this post from my github (available for my sponsors on BuyMeACoffee and Patreon)

If you like my content —  consider supporting me

Unlock exclusive access to the source code from the blog posts by joining my Patreon and Buy Me A Coffee communities!

Top comments (2)

Collapse
 
victorbustos2002 profile image
Victor Bustos

Excellent article, thanks...

Collapse
 
antonmartyniuk profile image
Anton Martyniuk

Thanks, for the feedback