Remigiusz ZalewskiRemigiusz Zalewski

EF Core - Lazy Loading, Eager Loading, and Explicit Loading

ef-core-loading-strategies

Introduction

If you've ever worked with Entity Framework Core, you've probably encountered the infamous N+1 query problem or wondered why your API returns incomplete data.

The culprit? Loading strategies.

Entity Framework Core provides three ways to load related data from your database:

  1. Lazy Loading - Load data automatically when accessed
  2. Eager Loading - Load everything upfront with a single query
  3. Explicit Loading - Manually control what gets loaded and when

Each approach has its trade-offs, and choosing the wrong one can lead to performance issues, unnecessary database round-trips, or incomplete data.

In this article, I'll walk you through all three strategies using a mocked version of "E-Commerce API" built with .NET 10 and Minimal APIs.


The Data Model

Before diving into the loading strategies, let's understand our data structure. We're building a simplified e-commerce system with the following entities:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
}

public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public int CustomerId { get; set; }
    public virtual Customer Customer { get; set; } = null!;
    public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public int OrderId { get; set; }
    public virtual Order Order { get; set; } = null!;
    public int ProductId { get; set; }
    public virtual Product Product { get; set; } = null!;
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public virtual Category Category { get; set; } = null!;
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

Entity Relationships

  • A Customer has many Orders
  • An Order has many OrderItems
  • Each OrderItem references one Product
  • Each Product belongs to one Category

This creates a deep object graph: Customer → Orders → OrderItems → Products → Categories


Project Setup

DbContext Configuration

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

    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();
}

Program.cs Setup

Here's what's important to set up in the DI container:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");

builder.Services.AddDbContext<SimplifiedECommerceDbContext>(options =>
    options.UseLazyLoadingProxies()  // Enable lazy loading
        .UseSqlServer(connectionString)
        .EnableSensitiveDataLogging());

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.ReferenceHandler =
        System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});

Key points:

  • UseLazyLoadingProxies() enables lazy loading support. You'll also need to install the NuGet package: Microsoft.EntityFrameworkCore.Proxies
  • ReferenceHandler.IgnoreCycles prevents JSON serialization errors when dealing with circular references in lazy-loaded entities.

🐢 Lazy Loading

Lazy loading is the automatic loading of related entities when you access their navigation properties. It's "lazy" because the data isn't loaded until you actually need it.

When you access a navigation property (like customer.Orders), Entity Framework Core:

  1. Detects the access
  2. Generates and executes a SQL query
  3. Loads the data
  4. Returns it to you
public async Task<List<Customer>> GetCustomersLazyAsync()
{
    Console.WriteLine("=== LAZY LOADING START ===");

    // Load customers only - no related data yet
    var customers = await _simplifiedECommerceDbContext.Customers
        .ToListAsync();

    Console.WriteLine($"Loaded {customers.Count} customers with full graph");

    // Accessing navigation properties triggers additional queries
    foreach (var customer in customers)
    {
        foreach (var order in customer.Orders)  // ← Query executed here
        {
            Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");

            foreach (var item in order.Items)  // ← Query executed here
            {
                // Accessing Product triggers another query
                Console.WriteLine(
                    $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
            }
        }
    }

    Console.WriteLine("=== LAZY LOADING END ===");
    return customers;
}

SQL Queries Generated

For 10 customers with 5 orders each, and 5 items per order, this generates:

SELECT * FROM Customers                     -- 1 query

SELECT * FROM Orders WHERE CustomerId = 1   -- 10 queries (one per customer)

SELECT * FROM OrderItems WHERE OrderId = X  -- 50 queries (one per order)

SELECT * FROM Products WHERE Id = Y         -- 250 queries (one per item)

Total: 311 database queries! This is the N+1 problem.

✅ Pros

  • Simple to implement
  • No need to specify Include()
  • Only loads data you actually use
  • Good for scenarios where related data is rarely accessed

❌ Cons

  • N+1 query problem - can cause hundreds or thousands of queries
  • Poor performance for deep object graphs
  • Requires virtual navigation properties
  • Requires UseLazyLoadingProxies()
  • Can cause issues with serialization

When to Use Lazy Loading

  • Simple, shallow data structures
  • When you rarely need related data
  • Development/prototyping phase
  • When database round-trips are cheap (same datacenter)

⚡ Eager Loading

Eager loading loads all related data upfront in a single query (or a few queries using split queries) using Include() and ThenInclude() methods.

You explicitly tell Entity Framework Core which related entities to load, and it generates an optimized SQL query with JOINs (or multiple parallel queries with split queries).

public async Task<List<Customer>> GetCustomersEagerAsync()
{
    Console.WriteLine("=== EAGER LOADING START ===");

    // Load customers with ALL related data in one go
    var customers = await _simplifiedECommerceDbContext.Customers
        .Include(x => x.Orders)                    // Load orders
        .ThenInclude(x => x.Items)                 // Load items
        .ThenInclude(x => x.Product)               // Load products
        .ThenInclude(x => x.Category)              // Load categories
        .AsSplitQuery()                            // Use multiple queries instead of one huge JOIN
        .AsNoTracking()                            // Don't track changes for read-only data
        .ToListAsync();

    Console.WriteLine($"Loaded {customers.Count} customers with full graph");

    // All data is already loaded - no additional queries
    foreach (var customer in customers)
    {
        foreach (var order in customer.Orders)
        {
            Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");

            foreach (var item in order.Items)
            {
                Console.WriteLine(
                    $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
            }
        }
    }

    Console.WriteLine("=== EAGER LOADING END ===");
    return customers;
}

SQL Queries Generated

With AsSplitQuery(), Entity Framework generates 5 separate queries that run in parallel:

-- Query 1: Get all customers
SELECT * FROM Customers

-- Query 2: Get all related orders
SELECT * FROM Orders WHERE CustomerId IN (1,2,3,...)

-- Query 3: Get all related order items
SELECT * FROM OrderItems WHERE OrderId IN (1,2,3,...)

-- Query 4: Get all related products
SELECT * FROM Products WHERE Id IN (1,2,3,...)

-- Query 5: Get all related categories
SELECT * FROM Categories WHERE Id IN (1,2,3,...)

Total: 5 queries instead of 311!

AsSplitQuery vs Single Query

Without AsSplitQuery(), EF Core generates one massive query with multiple JOINs, which can create a cartesian explosion - the result set can become enormous due to JOIN multiplication.

Example: 10 customers × 5 orders × 5 items = 250 rows returned for just 10 customers!

AsSplitQuery() solves this by breaking it into multiple queries.

AsNoTracking()

AsNoTracking() tells EF Core not to track changes to these entities. Use it for:

  • Read-only operations
  • API responses
  • Better performance (no change tracking overhead)

✅ Pros

  • Optimal performance - minimal database round-trips
  • Predictable query count
  • No N+1 problem
  • No need for virtual navigation properties
  • Works without lazy loading proxies
  • Best for API responses

❌ Cons

  • Can load unnecessary data if you don't need everything
  • More verbose code
  • Need to know the object graph structure upfront
  • Can cause cartesian explosion without split queries

When to Use Eager Loading

  • API endpoints that return DTOs
  • When you know you'll need related data
  • Performance-critical scenarios
  • Most common choice for production APIs

🎯 Explicit Loading

Explicit loading gives you fine-grained control over what gets loaded and when. You manually load related entities using Entry().Collection().LoadAsync() or Entry().Reference().LoadAsync().

You load the main entity first, then conditionally load related data based on business logic.

public async Task<List<Customer>> GetCustomersExplicitAsync()
{
    Console.WriteLine("=== EXPLICIT LOADING START ===");

    // Load only customers first
    var customers = await _simplifiedECommerceDbContext.Customers.ToListAsync();

    foreach (var customer in customers)
    {
        // Only load orders for first 5 customers
        if (customer.Id <= 5)
        {
            await _simplifiedECommerceDbContext.Entry(customer)
                .Collection(x => x.Orders)
                .LoadAsync();

            foreach (var order in customer.Orders)
            {
                Console.WriteLine($"  Order {order.Id} ({order.CreatedAt})");

                // Load items for each order
                await _simplifiedECommerceDbContext.Entry(order)
                    .Collection(o => o.Items)
                    .LoadAsync();

                foreach (var item in order.Items)
                {
                    // Only load product details for items with quantity >= 3
                    if (item.Quantity >= 3)
                    {
                        await _simplifiedECommerceDbContext.Entry(item)
                            .Reference(x => x.Product)
                            .LoadAsync();

                        Console.WriteLine(
                            $"Item {item.Id} | Product: {item.Product.Name} | Price: {item.Product.Price}");
                    }
                }
            }
        }
    }

    Console.WriteLine("=== EXPLICIT LOADING END ===");
    return customers;
}

Key Methods

  • Entry(entity) - Gets the entry for tracking and loading
  • .Collection(x => x.Property) - Loads a collection navigation property (1-to-many)
  • .Reference(x => x.Property) - Loads a reference navigation property (1-to-1 or many-to-1)
  • .LoadAsync() - Executes the load operation

Use Case: Conditional Loading

In the example above:

  • Orders are only loaded for customers 1–5
  • Product details are only loaded for items with quantity ≥ 3

This is useful when:

  • You have conditional business logic
  • Different users see different data (permissions)
  • You want to optimize based on data characteristics

✅ Pros

  • Maximum control over what gets loaded
  • Conditional loading based on business logic
  • Can optimize for specific scenarios
  • No unnecessary data loaded
  • Good for complex permission/filtering logic

❌ Cons

  • Most verbose approach
  • Requires more code
  • Easy to introduce N+1 if not careful
  • Harder to maintain
  • Requires understanding of EF Core entry API

When to Use Explicit Loading

  • Complex permission-based filtering
  • Conditional data loading based on business rules
  • Scenarios where eager loading would load too much
  • When you need surgical control over queries

🚀 Summary Table

StrategyQueries (10 customers)ControlComplexityBest For
Lazy Loading311 queriesAutomaticLowPrototyping, simple apps
Eager Loading5 queriesExplicit includesMediumProduction APIs
Explicit LoadingVariesFull manual controlHighConditional/permission logic

Key Takeaways

  • Lazy Loading - Automatic but causes N+1 problems. Use for prototyping or when related data is rarely needed.
  • Eager Loading - Optimal for APIs with predictable data needs. Use AsSplitQuery() to prevent cartesian explosion and AsNoTracking() for read-only operations.
  • Explicit Loading - Maximum control for conditional scenarios. Best when business logic dictates what data to load.
  • For most production APIs, Eager Loading with Split Queries is the winner.