Analyzer rule

LC017: Whole Entity Projection

LC017 flags EF Core queries that load whole entities when only a few properties are used. Project with Select to fetch just the needed columns.

Default severity
Info
Code fix
Available
Category
Performance

Change the severity in .editorconfig:

[*.cs]
dotnet_diagnostic.LC017.severity = error

Sample code · Analyzer source

LC017: Whole Entity Projection

In Plain Terms

Imagine you want to know your friend’s phone number. Instead of asking for just the number, you ask them to recite their entire autobiography—name, address, favorite foods, every vacation they’ve ever taken. You only needed one fact, but you got a whole book.

Overview

Diagnostic ID: LC017 Severity: Info Category: Performance

What It Detects

LC017 detects Entity Framework Core queries that load entire entities when only a small subset of properties are actually accessed. This is a common performance anti-pattern that wastes bandwidth, memory, and CPU.

The Crime

// Bad: Loads all 12 columns, but only uses Name
var products = context.Products.Where(p => p.Price > 100).ToList();
foreach (var p in products)
{
    Console.WriteLine(p.Name);  // Only Name is ever accessed!
}

When you query entities without a .Select() projection, EF Core retrieves ALL columns from the database. If your entity has many properties but you only use a few, you’re:

  • Transferring unnecessary data over the network
  • Allocating memory for unused properties
  • Potentially causing change tracking overhead for data you don’t need

The Fix

Use .Select() to project only the properties you need:

// Good: Projects only the needed property
var names = context.Products
    .Where(p => p.Price > 100)
    .Select(p => p.Name)
    .ToList();

foreach (var name in names)
{
    Console.WriteLine(name);
}

Or project into a DTO:

// Good: Project into a DTO with only needed fields
var products = context.Products
    .Where(p => p.Price > 100)
    .Select(p => new ProductSummary
    {
        Id = p.Id,
        Name = p.Name
    })
    .ToList();

Why This Matters

Performance Impact

Consider an entity with 12 properties, including a large Description field:

Approach Data Retrieved Memory Used
No projection All 12 columns (~2KB per row) ~2MB for 1000 rows
With .Select(p => p.Name) 1 column (~50 bytes per row) ~50KB for 1000 rows

That’s a 40x reduction in data transfer and memory usage.

Additional Benefits

  1. Faster queries: SQL Server only reads necessary columns from disk/memory
  2. No change tracking: Projections to non-entity types bypass EF’s change tracker
  3. Cleaner code: DTOs explicitly declare what data the code needs

Conservative Detection

LC017 uses conservative detection to minimize false positives:

  • Only flags large entities: Entities must have 10+ properties
  • Only flags clear waste: Must access only 1-2 properties of the entity
  • Only flags local usage: Skips when entities are returned from methods
  • Skips external method calls: If entity is passed to another method, can’t track usage
  • Skips updates: If a property of the entity is written (e.Status = ..., e.Count++, e.Total += x), the query loads tracked entities to change and save them, which a projection cannot do
  • Skips stored results: If the list or an entity is assigned to a field, property, another local, an object initializer, an array or a tuple, its later use is out of sight
  • Skips lambdas: If entity is used in a lambda/delegate, can’t reliably track
  • Collection queries only: Flags ToList()/ToArray(), not single-entity First()/Single()

When LC017 Does NOT Trigger

  1. Entities are changed: a write to the entity or through its navigations (e.Owner.Name = ..., e.Visits++), a change to a navigation collection (e.Tags.Add(...), Remove, Clear), or a call to one of the entity’s own methods (e.Archive()) means the method loads the entities to update them. A projection returns untracked values, so SaveChanges would persist nothing.
    // OK: the loop changes the tracked graph
    var libraries = context.Libraries.Include(l => l.FileTypes).ToList();
    foreach (var library in libraries)
        library.FileTypes.Add(new LibraryFileType { Name = "epub" });
    context.SaveChanges();
    
  2. Already using projection:
    // OK: Already projected
    var names = context.Products.Select(p => p.Name).ToList();
    
  3. Small entities:
    // OK: Entity has only 3 properties - not worth flagging
    var users = context.SmallEntities.ToList();
    
  4. Entity is returned:
    // OK: Can't track how caller uses the entity
    public List<Product> GetProducts() => context.Products.ToList();
    
  5. Entity passed to method:
    // OK: Can't track usage in external method
    var products = context.Products.ToList();
    ProcessProducts(products);
    
  6. Entities are updated:
    // OK: A projection has read-only members and nothing to save.
    // For a bulk change like this, consider ExecuteUpdate (LC032).
    var products = context.Products.Where(p => p.Discontinued).ToList();
    foreach (var p in products)
        p.UpdatedAt = now;
    context.SaveChanges();
    
  7. Result is stored:
    // OK: The view model is used elsewhere
    var products = context.Products.ToList();
    var vm = new CatalogViewModel { Products = products };
    
  8. Most properties accessed:
    // OK: Accessing 7+ of 12 properties justifies full load
    foreach (var p in context.Products.ToList())
    {
        Console.WriteLine($"{p.Id} {p.Name} {p.Description} {p.Price}...");
    }
    

Code Fix

LC017 provides an automatic code fix that adds a .Select() projection before the materializer. The fix:

  1. Analyzes property accesses: Determines which properties of the entity are actually used in subsequent code
  2. Generates anonymous type projection: Creates a .Select(e => new { e.Prop1, e.Prop2 }) with only the accessed properties
  3. Preserves supported access shapes: Includes properties used through direct foreach access, null-conditional access, and indexed collection access
  4. Declines unsafe conversion shapes: Keeps cast, interface, or conversion-based entity property access such as ((IHasName)e).Name or ((IHasName)e)?.Name diagnostic-only so the fix does not project a partial anonymous type while later code still needs the original entity
  5. Sorts properties alphabetically: Ensures consistent, predictable output

Before Fix

var entities = db.LargeEntities.ToList();
foreach (var e in entities)
{
    Console.WriteLine(e.Name);
}

After Fix

var entities = db.LargeEntities.Select(e => new { e.Name }).ToList();
foreach (var e in entities)
{
    Console.WriteLine(e.Name);
}

Note: After applying the fix, you may need to adjust your code to work with the anonymous type instead of the full entity. Consider creating a named DTO class for better maintainability.

Configuration

You can configure the severity in your .editorconfig:

# Make LC017 an error
dotnet_diagnostic.LC017.severity = error

# Disable LC017
dotnet_diagnostic.LC017.severity = none
  • LC002: Premature Materialization - Detects ToList() before filtering
  • LC009: Missing AsNoTracking - Suggests AsNoTracking() for read-only queries