TIL: You Don't Need AsNoTracking() When Projecting Entities With Entity Framework Core

Every time we retrieve an entity with Entity Framework Core, it will track those entities in its change tracker. And when calling .SaveChanges(), those changes are persisted to the database.

For read-only queries, we can add .AsNoTracking() to make them faster.

But when projecting an entity into a custom object, there’s no need to add AsNoTracking() since Entity Framework doesn’t track query results with a type different from the underlying entity type. Source

For example, let’s save some movies and retrieve them with and without a custom projection,

using Microsoft.EntityFrameworkCore;

namespace LookMaEntityFrameworkDoesNotTrackProjections;

public class Movie
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int ReleaseYear { get; set; }
}

public class MovieContext : DbContext
{
    public MovieContext(DbContextOptions<MovieContext> options) : base(options)
    {
    }

    public DbSet<Movie> Movies { get; set; }
}

[TestClass]
public class MovieTests
{
    [TestMethod]
    public void EFDoesNotTrackProjections()
    {
        var options = new DbContextOptionsBuilder<MovieContext>()
            .UseInMemoryDatabase(databaseName: "MovieDB")
            .Options;

        // 0. Saving two movies
        using (var context = new MovieContext(options))
        {
            context.Movies.AddRange(
                new Movie { Name = "Matrix", ReleaseYear = 1999 },
                new Movie {Name = "Titanic", ReleaseYear = 1997 }
            );
            context.SaveChanges();
        }

        // 1. Using a custom projection
        using (var context = new MovieContext(options))
        {
            var firstMovieNameAndReleaseYear
                = context.Movies
                    .Where(m => m.ReleaseYear >= 1990)
                    .Select(m => new { m.Name, m.ReleaseYear })
                    // ^^^^
                    // This is a custom projection
                    .First();

            var noTracking = context.ChangeTracker.Entries();
            Assert.AreEqual(0, noTracking.Count());
            //             ^^^
            // No entities tracked
        }

        // 2. Using AsNoTracking
        using (var context = new MovieContext(options))
        {
            var firstMovieWithNoTracking
                = context.Movies
                    .Where(m => m.ReleaseYear >= 1990)
                    .AsNoTracking()
                    // ^^^^^
                    .First();

            var withAsNoTracking = context.ChangeTracker.Entries();
            Assert.AreEqual(0, withAsNoTracking.Count());
            //             ^^^
            // As imply by its name, no entities tracked here
        }

        // 3. Retrieving a Movie
        using (var context = new MovieContext(options))
        {
            var firstMovie = context.Movies
                .Where(m => m.ReleaseYear >= 1990)
                .First();

            var beingTracked = context.ChangeTracker.Entries();
            Assert.AreEqual(1, beingTracked.Count());
            //             ^^^
            // Since we're retrieving only one Movie, tracking happens here
        }
    }
}

Only when we queried the first movie without a projection and without .AsNoTracking(), Entity Framework Core tracked the underlying entity.

Et voilà!

For more tricks with Entity Framework Core, read how to configure default values for nullable columns with default constraints.