DEV_NET_CORE
GET_STARTED
.NETEntity Framework

Tracking vs no-tracking queries and identity resolution

Overview

Tracking vs no-tracking queries are a core Entity Framework Core concept. They determine whether EF Core stores returned entity instances in the DbContext Change Tracker and whether those instances can later be automatically persisted with SaveChanges or SaveChangesAsync.

A tracking query is the default behavior for queries that return entity types with keys. EF Core keeps information about the returned entities, their original values, current values, relationships, and entity states. If the application modifies a tracked entity, EF Core can detect the change and generate the correct UPDATE, INSERT, or DELETE commands when saving.

A no-tracking query tells EF Core not to keep the returned entities in the Change Tracker. This is commonly used for read-only screens, API GET endpoints, reports, search results, dropdown lists, exports, and projections where the application does not intend to update the returned entities in the same DbContext unit of work.

Identity resolution means EF Core ensures that one database row with a given primary key is represented by one object instance within a tracking context. For example, if the same Customer appears multiple times in a query result because several Order rows reference it, identity resolution can make those references point to the same Customer object instance instead of creating duplicates.

This topic matters because tracking behavior affects correctness, performance, memory usage, update behavior, relationship fix-up, and API design. Misunderstanding it can cause common production bugs such as:

  • Accidentally changing data from what was intended to be a read-only query.
  • Loading too many tracked entities and increasing memory usage.
  • Returning duplicate object instances in no-tracking graph queries.
  • Getting stale data from a long-lived DbContext.
  • Attaching no-tracking entities incorrectly and causing duplicate key tracking errors.
  • Assuming DTO projections are tracked when they are not.
  • Assuming all projections are no-tracking when they still contain entity instances.

It is important for interviews because it tests whether a developer understands how EF Core behaves beyond basic LINQ syntax. A strong candidate should know when to use tracking, when to use AsNoTracking, when AsNoTrackingWithIdentityResolution is useful, how DbContext identity resolution works, and how these choices affect real API and service-layer code.

Typical real-world use cases include:

  • Using tracking queries for command/update workflows.
  • Using no-tracking queries for read-only API responses.
  • Projecting database data directly into DTOs.
  • Avoiding unnecessary Change Tracker overhead in large reads.
  • Loading complex object graphs without duplicated entity instances.
  • Configuring default query tracking behavior for read-heavy applications.
  • Debugging why an entity update was or was not saved.
  • Debugging duplicate instance errors in disconnected entity workflows.

Core Concepts

Change Tracking

Change tracking is EF Core's mechanism for remembering entity instances and detecting how they changed during a unit of work.

When an entity is tracked, EF Core records information such as:

  • The entity type.
  • The primary key value.
  • The entity state, such as Unchanged, Modified, Added, Deleted, or Detached.
  • Original property values.
  • Current property values.
  • Relationship information.
  • Navigation property fix-up information.

Example of a tracking query:

Code
var customer = await dbContext.Customers
    .SingleAsync(c => c.Id == customerId, cancellationToken);

customer.Name = "Updated Name";

await dbContext.SaveChangesAsync(cancellationToken);

In this example, customer is tracked because EF Core tracks entity queries by default. The property change is detected and persisted when SaveChangesAsync is called.

The important point is that tracking is useful when the application intends to modify the entity within the same DbContext unit of work.

Tracking Queries

A tracking query returns entities and stores them in the current DbContext Change Tracker.

Example:

Code
var orders = await dbContext.Orders
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

If Order is a normal entity type with a key, the returned orders are tracked by default.

Tracking queries are useful when:

  • The application needs to update the returned entities.
  • The application needs relationship fix-up between loaded entities.
  • The same entity may already be tracked and should be reused.
  • The unit of work is command-oriented rather than read-only.
  • EF Core should generate updates only for changed properties.

Example of a command workflow:

Code
public async Task RenameCustomerAsync(
    int customerId,
    string newName,
    CancellationToken cancellationToken)
{
    var customer = await dbContext.Customers
        .SingleAsync(c => c.Id == customerId, cancellationToken);

    customer.Name = newName;

    await dbContext.SaveChangesAsync(cancellationToken);
}

This is a good use of tracking because EF Core can compare original and current values and save only the relevant changes.

No-Tracking Queries

A no-tracking query returns entities without storing them in the current DbContext Change Tracker.

Example:

Code
var customers = await dbContext.Customers
    .AsNoTracking()
    .Where(c => c.IsActive)
    .ToListAsync(cancellationToken);

No-tracking queries are useful when:

  • The result is read-only.
  • The data will be serialized to an API response.
  • The application projects data into DTOs.
  • The query returns a large number of rows.
  • Change tracking would add unnecessary memory or CPU overhead.
  • The result should reflect database values without considering local tracked changes.

Example read-only API query:

Code
public async Task<IReadOnlyList<CustomerListItemDto>> GetCustomersAsync(
    CancellationToken cancellationToken)
{
    return await dbContext.Customers
        .AsNoTracking()
        .Where(c => c.IsActive)
        .OrderBy(c => c.Name)
        .Select(c => new CustomerListItemDto
        {
            Id = c.Id,
            Name = c.Name,
            Email = c.Email
        })
        .ToListAsync(cancellationToken);
}

In many read-only cases, the best option is not just AsNoTracking, but projection to a DTO. Projection reduces the selected columns and avoids exposing entity models directly.

Tracking vs No-Tracking Comparison

AreaTracking QueryNo-Tracking Query
Default behaviorYes, for entity types with keysMust be requested with AsNoTracking or configured globally
Change Tracker usageUses the current DbContext Change TrackerDoes not use the current DbContext Change Tracker
Save changes automaticallyYes, if entity is modified and SaveChanges is calledNo, unless the entity is later attached or updated explicitly
Identity resolutionYesNo, except with AsNoTrackingWithIdentityResolution
Memory overheadHigher for large readsLower for simple read-only reads
Best forUpdates and unit-of-work workflowsRead-only queries and DTO responses
Navigation fix-upUses tracked relationshipsDoes not perform normal context tracking fix-up
RiskLong-lived contexts can hold stale data and many tracked entitiesAccidentally modifying returned entities will not be saved automatically

Identity Resolution

Identity resolution means EF Core makes sure that only one object instance represents a specific entity key within a tracking context.

For example, assume multiple orders belong to the same customer:

Code
var orders = await dbContext.Orders
    .Include(o => o.Customer)
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

With a tracking query, if several orders reference the same customer row, EF Core uses one Customer object instance for that key.

Conceptually:

Code
var firstCustomer = orders[0].Customer;
var secondCustomer = orders[1].Customer;

bool sameInstance = ReferenceEquals(firstCustomer, secondCustomer); // usually true in tracking query

Identity resolution matters because EF Core must maintain a consistent object graph. If two different object instances with the same primary key were tracked at the same time, EF Core would not know which property values and relationships are the authoritative version.

Identity Map Mental Model

A useful mental model is that the DbContext maintains an identity map.

The identity map is like a dictionary:

Code
(EntityType, PrimaryKeyValue) -> EntityInstance

When EF Core materializes an entity from a tracking query, it checks whether that entity key is already tracked.

If the entity is already tracked:

  • EF Core returns the existing object instance.
  • The same object reference is reused.
  • The tracked entity's current and original values are not automatically overwritten by the new query result.

If the entity is not already tracked:

  • EF Core creates a new object instance.
  • EF Core stores it in the Change Tracker.
  • EF Core marks it as Unchanged initially.

This behavior is why a short-lived DbContext is important. A long-lived context can return already-tracked instances that may not reflect the latest database values.

Tracking Query Returning an Existing Tracked Instance

Consider this example:

Code
var customer = await dbContext.Customers
    .SingleAsync(c => c.Id == 1, cancellationToken);

customer.Name = "Local Unsaved Name";

var sameCustomer = await dbContext.Customers
    .SingleAsync(c => c.Id == 1, cancellationToken);

Console.WriteLine(ReferenceEquals(customer, sameCustomer)); // True
Console.WriteLine(sameCustomer.Name); // Local Unsaved Name

The second query still goes to the database to evaluate the query, but because the entity with key 1 is already tracked, EF Core returns the existing tracked instance.

This is useful for consistency within a unit of work, but it can surprise developers who expect every query to refresh the entity from the database.

If fresh database values are required, common options include:

Code
await dbContext.Entry(customer).ReloadAsync(cancellationToken);

or using a new short-lived DbContext for a new unit of work.

No-Tracking Queries and Duplicate Instances

A normal no-tracking query does not perform identity resolution.

Example:

Code
var orders = await dbContext.Orders
    .AsNoTracking()
    .Include(o => o.Customer)
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

If the same customer appears multiple times in the result graph, EF Core may create separate Customer object instances for the same database row.

This is often acceptable for read-only API responses because the objects are usually serialized and discarded. However, it can matter when:

  • The application compares object references.
  • The result graph is large and duplicates increase memory usage.
  • Multiple result objects should share the same related entity instance.
  • The query produces repeated references to the same entity.

AsNoTrackingWithIdentityResolution

AsNoTrackingWithIdentityResolution gives a middle-ground behavior:

  • The result is not tracked by the current DbContext.
  • EF Core still performs identity resolution within the result of that query.
  • A temporary internal tracker is used only while materializing the query result.
  • After the query is fully enumerated, the temporary tracking structure can be discarded.

Example:

Code
var orders = await dbContext.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(o => o.Customer)
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

This is useful for read-only graph queries where the same entity may appear multiple times and duplicate object instances would be wasteful or confusing.

Use AsNoTrackingWithIdentityResolution when:

  • The result is read-only.
  • The query returns an object graph with repeated entity references.
  • You want one object instance per key within the query result.
  • You do not want the entities tracked by the DbContext after the query.

Avoid assuming it is always faster than AsNoTracking. It does extra work to resolve identities, so simple flat read-only queries may be better with plain AsNoTracking or direct DTO projection.

AsTracking

AsTracking explicitly requests tracking behavior for a query.

Example:

Code
var customer = await dbContext.Customers
    .AsTracking()
    .SingleAsync(c => c.Id == customerId, cancellationToken);

This is useful when the default query tracking behavior has been configured as no-tracking, but one specific query needs to update entities.

Example:

Code
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

Then for update workflows:

Code
var customer = await dbContext.Customers
    .AsTracking()
    .SingleAsync(c => c.Id == customerId, cancellationToken);

customer.Name = request.Name;

await dbContext.SaveChangesAsync(cancellationToken);

This pattern can work in read-heavy applications, but teams must be disciplined. Forgetting AsTracking in command code can result in changes not being saved.

QueryTrackingBehavior

EF Core allows configuring tracking behavior at different levels:

  1. Per query:
Code
var customers = await dbContext.Customers
    .AsNoTracking()
    .ToListAsync(cancellationToken);
  1. Per context instance:
Code
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
  1. In DbContext options:
Code
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

Common enum values include:

  • TrackAll: track query results by default.
  • NoTracking: do not track query results by default.
  • NoTrackingWithIdentityResolution: do not track results in the context, but perform identity resolution for query results.

For most business applications, a common practice is:

  • Use default tracking for command/update workflows.
  • Use AsNoTracking or DTO projections explicitly for read-only query workflows.

Changing the entire context default to no-tracking can be helpful in read-only services, reporting services, or query-side contexts, but it can create bugs if update code assumes tracking is enabled.

DTO Projection and Tracking

Projection means selecting only the shape required by the application instead of returning full entity objects.

Example:

Code
var customers = await dbContext.Customers
    .Where(c => c.IsActive)
    .Select(c => new CustomerListItemDto
    {
        Id = c.Id,
        Name = c.Name,
        OrderCount = c.Orders.Count
    })
    .ToListAsync(cancellationToken);

If the projection contains only scalar values and DTOs, EF Core does not track entity instances from the result because no entity instances are returned.

This is often better than returning entities for read-only API responses because it:

  • Selects fewer columns.
  • Avoids unnecessary tracking.
  • Avoids exposing persistence models directly.
  • Produces a clear request/response contract.
  • Reduces accidental data leakage.

However, if the projection includes an entity instance, that entity can still be tracked by default:

Code
var result = await dbContext.Customers
    .Select(c => new
    {
        Customer = c,
        OrderCount = c.Orders.Count
    })
    .ToListAsync(cancellationToken);

In this query, Customer is an entity instance, so it can be tracked unless no-tracking is applied.

To avoid tracking:

Code
var result = await dbContext.Customers
    .AsNoTracking()
    .Select(c => new
    {
        Customer = c,
        OrderCount = c.Orders.Count
    })
    .ToListAsync(cancellationToken);

A very important interview point is: projection to a DTO with scalar properties is naturally not tracking entity instances, but projection that contains entity objects can still track those entity objects.

Keyless Entity Types

Keyless entity types are never tracked because EF Core does not have a primary key to identify one instance as the same conceptual entity.

Keyless entity types are commonly used for:

  • Database views.
  • Raw SQL query shapes.
  • Report models.
  • Read-only projections.

Example:

Code
public sealed class MonthlySalesReport
{
    public string Month { get; set; } = string.Empty;
    public decimal TotalSales { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<MonthlySalesReport>()
        .HasNoKey()
        .ToView("View_MonthlySalesReport");
}

Because there is no key, EF Core cannot perform normal identity resolution or update tracking for this type.

Relationship Fix-Up

Relationship fix-up is EF Core's process of keeping navigation properties and foreign key values consistent among tracked entities.

Example:

Code
var customer = await dbContext.Customers
    .SingleAsync(c => c.Id == customerId, cancellationToken);

var orders = await dbContext.Orders
    .Where(o => o.CustomerId == customerId)
    .ToListAsync(cancellationToken);

Because the entities are tracked, EF Core can connect the relationship in memory. The customer's Orders navigation may be populated with the orders that were loaded, and each order's Customer navigation may point to the tracked customer.

This can be useful, but it can also surprise developers when a long-lived context already contains related entities. The result graph may include relationships from the Change Tracker that were loaded by earlier queries in the same context.

For clean read-only API results, DTO projection is often safer and more predictable than returning tracked entity graphs.

Tracking and Filtered Include

Filtered include allows applying operations such as Where, OrderBy, Skip, and Take inside an Include.

Example:

Code
var customers = await dbContext.Customers
    .Include(c => c.Orders.Where(o => o.Status == OrderStatus.Open))
    .ToListAsync(cancellationToken);

With tracking queries, previously tracked related entities can affect the final navigation property contents through relationship fix-up. This means the in-memory navigation collection may contain entities that were loaded earlier in the context, even if they do not match the filtered include.

For predictable read-only results with filtered includes, consider:

Code
var customers = await dbContext.Customers
    .AsNoTracking()
    .Include(c => c.Orders.Where(o => o.Status == OrderStatus.Open))
    .ToListAsync(cancellationToken);

Or better, project directly to a DTO:

Code
var customers = await dbContext.Customers
    .Select(c => new CustomerOrdersDto
    {
        CustomerId = c.Id,
        CustomerName = c.Name,
        OpenOrders = c.Orders
            .Where(o => o.Status == OrderStatus.Open)
            .Select(o => new OrderDto
            {
                Id = o.Id,
                OrderDate = o.OrderDate,
                Total = o.Total
            })
            .ToList()
    })
    .ToListAsync(cancellationToken);

Updating Entities: Tracking Query Approach

The simplest and safest update pattern is to query the entity with tracking, modify allowed properties, and save.

Example:

Code
public async Task UpdateCustomerAsync(
    int id,
    UpdateCustomerRequest request,
    CancellationToken cancellationToken)
{
    var customer = await dbContext.Customers
        .SingleOrDefaultAsync(c => c.Id == id, cancellationToken);

    if (customer is null)
    {
        throw new KeyNotFoundException("Customer was not found.");
    }

    customer.Name = request.Name;
    customer.Email = request.Email;

    await dbContext.SaveChangesAsync(cancellationToken);
}

Benefits:

  • Easy to reason about.
  • Updates only changed properties.
  • Preserves concurrency tokens and shadow properties.
  • Avoids accidentally overwriting fields not included in the request.
  • Avoids attaching duplicate instances.

Trade-off:

  • Requires a database read before the update.

For many business applications, this trade-off is acceptable because it improves correctness and validation clarity.

Updating Entities: No-Tracking Query Pitfall

A common mistake is querying with AsNoTracking, modifying the entity, and expecting SaveChanges to persist it.

Problem example:

Code
var customer = await dbContext.Customers
    .AsNoTracking()
    .SingleAsync(c => c.Id == customerId, cancellationToken);

customer.Name = "New Name";

await dbContext.SaveChangesAsync(cancellationToken); // No update is saved

The entity is not tracked, so EF Core does not know it changed.

Possible fixes:

  1. Remove AsNoTracking for update workflows:
Code
var customer = await dbContext.Customers
    .SingleAsync(c => c.Id == customerId, cancellationToken);

customer.Name = "New Name";

await dbContext.SaveChangesAsync(cancellationToken);
  1. Attach and mark specific properties as modified when using a disconnected entity:
Code
var customer = new Customer
{
    Id = customerId,
    Name = request.Name
};

dbContext.Attach(customer);
dbContext.Entry(customer).Property(c => c.Name).IsModified = true;

await dbContext.SaveChangesAsync(cancellationToken);

Use the second approach carefully. It is useful for optimized updates but requires discipline to avoid overwriting data incorrectly.

Disconnected Entities and Duplicate Tracking Errors

Disconnected entities are objects created outside the current DbContext, often from API requests, JSON payloads, message queues, or UI forms.

A common error occurs when the context already tracks an entity with a given key, and the application tries to attach another instance with the same key.

Problem example:

Code
var existingCustomer = await dbContext.Customers
    .SingleAsync(c => c.Id == request.Id, cancellationToken);

var detachedCustomer = new Customer
{
    Id = request.Id,
    Name = request.Name
};

dbContext.Update(detachedCustomer); // Can throw if another instance with same key is already tracked

Better approach:

Code
var customer = await dbContext.Customers
    .SingleAsync(c => c.Id == request.Id, cancellationToken);

customer.Name = request.Name;
customer.Email = request.Email;

await dbContext.SaveChangesAsync(cancellationToken);

Another approach when you intentionally do not query first:

Code
var customer = new Customer
{
    Id = request.Id,
    Name = request.Name,
    Email = request.Email
};

dbContext.Customers.Update(customer);
await dbContext.SaveChangesAsync(cancellationToken);

This marks the entity as modified, often causing all mapped properties to be updated. It can be acceptable in some simple cases, but it is risky when the request does not contain every property.

Attach vs Update vs Tracking Query

PatternBehaviorBest Use CaseRisk
Tracking query then modifyLoads and tracks entity, then detects changed propertiesBusiness updates with validationRequires a read before update
AttachStarts tracking existing entity as usually unchangedUpdating selected properties or connecting existing related entitiesMust manually mark modified properties if needed
UpdateStarts tracking entity graph as modified where appropriateSimple disconnected full updateCan update too many columns or overwrite data
AsNoTracking then modifyDoes not track resultRead-only onlyChanges are not saved unless attached later

Interview-friendly rule:

Use tracking queries for normal updates. Use no-tracking queries for read-only results. Use Attach or Update only when you intentionally handle disconnected entities and understand the update consequences.

Performance Considerations

Tracking has overhead because EF Core stores tracking information, original values, relationship information, and identity map entries.

For large read-only queries, this overhead can matter.

Example:

Code
var reportRows = await dbContext.Orders
    .AsNoTracking()
    .Where(o => o.OrderDate >= startDate && o.OrderDate < endDate)
    .Select(o => new OrderReportRowDto
    {
        OrderId = o.Id,
        CustomerName = o.Customer.Name,
        Total = o.Total,
        OrderDate = o.OrderDate
    })
    .ToListAsync(cancellationToken);

This is usually better than loading full tracked Order entities for a report.

However, no-tracking is not automatically faster in every scenario. For example, if a query returns the same entity many times in a complex graph, no-tracking may create duplicate instances. In those cases, AsNoTrackingWithIdentityResolution or a tracking query may use fewer object instances.

Performance should be guided by the query shape:

  • Flat read-only DTO query: projection is usually best.
  • Simple read-only entity query: AsNoTracking is often appropriate.
  • Read-only graph with repeated references: consider AsNoTrackingWithIdentityResolution.
  • Update workflow: use tracking.
  • Very large result set: avoid tracking and consider streaming, pagination, or batching.

Memory Considerations

A long-lived DbContext can accumulate tracked entities over time.

Problem pattern:

Code
foreach (var batch in batches)
{
    var customers = await dbContext.Customers
        .Where(c => batch.CustomerIds.Contains(c.Id))
        .ToListAsync(cancellationToken);

    // Process many batches with the same DbContext...
}

If this processes many batches, the Change Tracker can grow large.

Possible improvements:

  • Use a new context per batch.
  • Use AsNoTracking for read-only processing.
  • Clear the Change Tracker between batches when appropriate.
  • Use DTO projection.
  • Avoid loading more rows than necessary.

Example:

Code
foreach (var batch in batches)
{
    var rows = await dbContext.Customers
        .AsNoTracking()
        .Where(c => batch.CustomerIds.Contains(c.Id))
        .Select(c => new CustomerProcessingDto
        {
            Id = c.Id,
            Name = c.Name
        })
        .ToListAsync(cancellationToken);

    // Process read-only rows...
}

Read Models and CQRS

In CQRS-style applications, query handlers and command handlers often use different patterns.

Query handler:

Code
public async Task<IReadOnlyList<CustomerListItemDto>> Handle(
    GetCustomersQuery query,
    CancellationToken cancellationToken)
{
    return await dbContext.Customers
        .AsNoTracking()
        .Where(c => c.IsActive)
        .Select(c => new CustomerListItemDto
        {
            Id = c.Id,
            Name = c.Name
        })
        .ToListAsync(cancellationToken);
}

Command handler:

Code
public async Task Handle(
    RenameCustomerCommand command,
    CancellationToken cancellationToken)
{
    var customer = await dbContext.Customers
        .SingleAsync(c => c.Id == command.CustomerId, cancellationToken);

    customer.Rename(command.NewName);

    await dbContext.SaveChangesAsync(cancellationToken);
}

This separation is easy to explain in interviews:

  • Queries should be optimized for read shape and usually use no-tracking DTO projection.
  • Commands should load tracked aggregates/entities, apply business rules, and save changes.

API Design Habits

For Web APIs, avoid returning EF Core entities directly from controllers.

Less ideal:

Code
[HttpGet]
public async Task<List<Customer>> GetCustomers(CancellationToken cancellationToken)
{
    return await dbContext.Customers.ToListAsync(cancellationToken);
}

Better:

Code
[HttpGet]
public async Task<List<CustomerListItemDto>> GetCustomers(CancellationToken cancellationToken)
{
    return await dbContext.Customers
        .AsNoTracking()
        .OrderBy(c => c.Name)
        .Select(c => new CustomerListItemDto
        {
            Id = c.Id,
            Name = c.Name,
            Email = c.Email
        })
        .ToListAsync(cancellationToken);
}

Benefits:

  • Clear API contract.
  • Fewer selected columns.
  • No accidental tracking overhead.
  • No circular navigation serialization issues.
  • No accidental exposure of internal fields.
  • Easier versioning and validation.

Stale Data and Long-Lived DbContext

Because tracking queries reuse existing tracked instances, a long-lived DbContext can return stale in-memory data.

Example scenario:

  1. A customer is loaded and tracked.
  2. Another request or process updates the same customer in the database.
  3. The original context queries the customer again.
  4. EF Core returns the already-tracked instance.

This is one reason DbContext should usually be short-lived.

For ASP.NET Core applications, registering DbContext as scoped usually matches the request unit of work:

Code
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
});

Avoid:

  • Singleton DbContext.
  • Static DbContext.
  • Sharing one DbContext across unrelated operations.
  • Reusing the same DbContext for many user interactions.
  • Running parallel operations on the same DbContext.

No-Tracking and Lazy Loading

If an application uses lazy loading proxies, no-tracking queries can affect expectations around navigation loading. Lazy loading depends on EF infrastructure and a live context. Read-only no-tracking results should not be designed around lazy loading behavior.

Better practice:

  • Use explicit DTO projections for API responses.
  • Use Include only when entity graphs are actually needed.
  • Avoid relying on lazy loading in Web APIs.
  • Prefer predictable query shapes.

Example DTO projection instead of lazy loading:

Code
var orders = await dbContext.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == customerId)
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        OrderDate = o.OrderDate,
        CustomerName = o.Customer.Name,
        Total = o.Total
    })
    .ToListAsync(cancellationToken);

Split Queries, Includes, and Tracking

When loading multiple collections with Include, EF Core can generate large joins that repeat data. Split queries can reduce cartesian explosion by using multiple SQL queries.

Example:

Code
var customers = await dbContext.Customers
    .AsNoTrackingWithIdentityResolution()
    .AsSplitQuery()
    .Include(c => c.Orders)
    .Include(c => c.SupportTickets)
    .ToListAsync(cancellationToken);

This kind of query involves multiple concerns:

  • AsNoTrackingWithIdentityResolution avoids tracking results in the context but prevents duplicate object instances for the same key in the result.
  • AsSplitQuery can reduce result duplication from large joins.
  • Include loads full related entities, which may be heavier than DTO projection.

For API endpoints, compare this with DTO projection before choosing it.

Choosing the Right Query Mode

A practical decision flow:

  1. Will you modify and save the returned entity in the same unit of work?

    • Use a tracking query.
  2. Is this a read-only API or report?

    • Use DTO projection, usually with AsNoTracking if entity instances are involved.
  3. Does the read-only result contain repeated references to the same entity?

    • Consider AsNoTrackingWithIdentityResolution.
  4. Are you returning only scalar values or DTOs without entity instances?

    • Tracking is usually not relevant because no entity instances are returned.
  5. Is the DbContext configured globally as no-tracking?

    • Use AsTracking explicitly for update workflows.
  6. Are you tempted to query no-tracking and then attach the entity to update it?

    • Consider a tracking query update pattern instead. It is often simpler and safer.

Common Mistakes

Common mistakes include:

  • Using AsNoTracking in update workflows and expecting SaveChanges to persist modifications.
  • Returning entities directly from API endpoints instead of DTOs.
  • Assuming no-tracking queries perform identity resolution by default.
  • Assuming AsNoTrackingWithIdentityResolution tracks entities in the DbContext.
  • Using a long-lived DbContext and getting stale tracked data.
  • Attaching a second instance with the same key while another instance is already tracked.
  • Globally disabling tracking and forgetting AsTracking in commands.
  • Using Update on a partial request DTO and overwriting columns unintentionally.
  • Assuming projection always disables tracking, even when the projection includes entity instances.
  • Loading large tracked graphs for read-only screens.

Best Practices

Good habits include:

  • Use tracking queries for normal update workflows.
  • Use AsNoTracking for read-only entity queries.
  • Prefer DTO projection for API responses and reports.
  • Use AsNoTrackingWithIdentityResolution for read-only graph queries with repeated entity references.
  • Keep DbContext short-lived.
  • Avoid sharing DbContext across threads.
  • Avoid returning EF entities from controllers.
  • Be careful when changing default QueryTrackingBehavior.
  • Avoid attaching no-tracking entities to the same context unless the workflow is intentional.
  • Use AsTracking explicitly when the default is no-tracking but an update is required.
  • Profile real queries instead of assuming one mode is always faster.

Interview Practice

PreviousOptimistic concurrency, transactions, savepoints, and conflict handlingNext UpCode Coverage, Useful Assertions, Flaky Test Prevention, and CI Test Execution