# EF Core Lazy Loading vs Eager Loading vs Explicit Loading

> EF Core gives you three ways to load related data: eager loading, explicit loading, and lazy loading. Each has trade-offs. Picking the right one can mean the difference between a fast query and the N+1 problem.

Published: 2026-08-18. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/lazy-eager-explicit-loading-ef-core

EF Core gives you three ways to load related data, and they differ in when the query runs.
Eager loading fetches related data upfront in the same query with `Include`.
Explicit loading waits until the parent entity is in memory, then issues one query per `LoadAsync` call.
Lazy loading fires a query the first time you touch a navigation property, which is where the N+1 problem comes from.

Eager loading is the best default for most scenarios.
Use explicit loading for conditional paths, and avoid lazy loading in production.

## The Related Data Problem

When you query an `Order`, should EF Core also load the `OrderLineItems`? What about the `Customer`? Loading everything upfront is wasteful. Loading nothing means extra round trips later.

EF Core provides three strategies: **eager loading**, **explicit loading**, and **lazy loading**. Understanding when to use each one is critical for [**performance**](https://milanjovanovic.tech/blog/ef-core-performance-guide).

## Eager Loading With Include

Eager loading fetches related data in the same query using `Include` and `ThenInclude`:

```csharp
var order = await context.Orders
    .Include(o => o.LineItems)
    .Include(o => o.Customer)
    .FirstOrDefaultAsync(o => o.Id == orderId);
```

EF Core generates a single query with `JOIN`s:

```sql
SELECT o.*, li.*, c.*
FROM "Orders" o
LEFT JOIN "OrderLineItems" li ON li."OrderId" = o."Id"
LEFT JOIN "Customers" c ON c."Id" = o."CustomerId"
WHERE o."Id" = @p0;
```

For nested relationships, use `ThenInclude`:

```csharp
var order = await context.Orders
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .FirstOrDefaultAsync(o => o.Id == orderId);
```

### Filtered Includes

Since EF Core 5, you can filter what gets included:

```csharp
var order = await context.Orders
    .Include(o => o.LineItems
        .Where(li => li.Quantity > 0)
        .OrderBy(li => li.Price))
    .FirstOrDefaultAsync(o => o.Id == orderId);
```

This is useful when you don't need all related entities - only a subset.

### When to Use Eager Loading

Eager loading is the best default for most scenarios. You know exactly which relationships you need upfront, and EF Core fetches them in a single round trip. The downside is that large `Include` chains can produce massive SQL queries with many `JOIN`s.

When you include multiple sibling collections, the joined result set multiplies (a cartesian explosion).
Use `AsSplitQuery()` in those cases, and measure the round-trip versus duplication tradeoff described in [**EF Core query splitting**](https://milanjovanovic.tech/blog/how-to-improve-performance-with-ef-core-query-splitting).

## Explicit Loading

Explicit loading lets you load related data **on demand** after the parent entity is already in memory:

```csharp
var order = await context.Orders
    .FirstOrDefaultAsync(o => o.Id == orderId);

// Load line items explicitly
await context.Entry(order)
    .Collection(o => o.LineItems)
    .LoadAsync();

// Load a single reference
await context.Entry(order)
    .Reference(o => o.Customer)
    .LoadAsync();
```

Each `LoadAsync` call issues a separate `SELECT` query. This gives you fine-grained control over when data is fetched.

### Querying Before Loading

You can also apply filters or projections before loading:

```csharp
var expensiveItems = await context.Entry(order)
    .Collection(o => o.LineItems)
    .Query()
    .Where(li => li.Price > 100)
    .ToListAsync();
```

The `Query()` method returns an `IQueryable` that you can chain with any LINQ operator. This avoids loading the entire collection when you only need a subset.

### When to Use Explicit Loading

Explicit loading works well when:

- You conditionally need related data based on runtime logic
- You want to avoid massive `JOIN` queries
- You've loaded an entity from the [**change tracker**](https://milanjovanovic.tech/blog/change-tracker-ef-core) and need its relationships later

## Lazy Loading

Lazy loading automatically fetches related data the first time you access a navigation property. EF Core intercepts the property access and issues a query behind the scenes.

### Setting Up Lazy Loading

Install the `Microsoft.EntityFrameworkCore.Proxies` package and enable it:

```csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString)
           .UseLazyLoadingProxies());
```

Mark navigation properties as `virtual` so EF Core can create proxy classes:

```csharp
public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; }

    public Guid CustomerId { get; set; }
    public virtual Customer Customer { get; set; }

    public virtual ICollection<OrderLineItem> LineItems { get; set; }
}
```

Now accessing `order.LineItems` triggers a query automatically:

```csharp
var order = await context.Orders
    .FirstOrDefaultAsync(o => o.Id == orderId);

// This triggers a SELECT query for line items
foreach (var item in order.LineItems)
{
    // This triggers ANOTHER query for each product
    Console.WriteLine(item.Product.Name);
}
```

### The N+1 Problem

That last example has a serious performance problem. If the order has 50 line items, accessing `item.Product` in the loop fires **50 separate queries** - one for each product. Combined with the initial order query and the line items query, that's 52 queries total.

This is the [**N+1 problem**](https://milanjovanovic.tech/blog/n-plus-one-query-ef-core), and it's the biggest risk of lazy loading. Everything looks correct, but the app is hammering the database.

### Lazy Loading Without Proxies

If you want lazy loading for a specific entity without proxying your whole model, EF Core supports injecting `ILazyLoader`:

```csharp
public class Order
{
    private readonly ILazyLoader _lazyLoader;
    private List<OrderLineItem> _lineItems;

    public Order()
    {
    }

    private Order(ILazyLoader lazyLoader)
    {
        _lazyLoader = lazyLoader;
    }

    public List<OrderLineItem> LineItems
    {
        get => _lazyLoader.Load(this, ref _lineItems);
        set => _lineItems = value;
    }
}
```

EF Core injects `ILazyLoader` through the private constructor when it materializes the entity.
The `Load` extension method (from `Microsoft.EntityFrameworkCore.Infrastructure`) queries the collection on first access and is a no-op when the loader is `null`, so `new Order()` still works in tests.
It is more explicit than proxies, but it couples entities to an EF Core interface and rarely justifies that tradeoff.

### Detecting N+1 Queries

You can [**log SQL queries**](https://milanjovanovic.tech/blog/log-sql-queries-ef-core) to catch this in development:

```csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());
```

If you see a pattern of many similar `SELECT` statements inside a loop, you've found an N+1 problem.

## Comparing the Three Strategies

![The three related-data loading strategies branching from a single decision: eager loading with Include runs one query with JOINs, explicit loading with LoadAsync runs one query per call on demand, and lazy loading runs a query on each property access with N+1 risk](https://milanjovanovic.tech/blogs/articles/lazy-eager-explicit-loading-ef-core/loading-strategies.png)

- **Eager loading**: one query with JOINs (or split queries), you decide upfront what to load, no N+1 risk. Best for relationships you always need.
- **Explicit loading**: one query per `LoadAsync` call, full on-demand control, low N+1 risk. Best for conditional loading.
- **Lazy loading**: one query per navigation property access, fully automatic, high N+1 risk. Acceptable for prototyping, dangerous in production.

## My Recommendations

A practical default is:

**Use eager loading as the default.** When you write a query, you almost always know what related data you need. Use `Include` to fetch it upfront.

**Use explicit loading for conditional paths.** If you only need line items when the order status is Confirmed, use explicit loading to avoid unnecessary data.

**Avoid lazy loading in production.** Lazy loading hides database queries behind property access. It makes performance problems invisible until they hit production. If you must use it, treat it as a shortcut for prototyping - not a production strategy.

```csharp
// ✅ Eager - clear intent, single round trip
var order = await context.Orders
    .Include(o => o.LineItems)
        .ThenInclude(li => li.Product)
    .Include(o => o.Customer)
    .FirstOrDefaultAsync(o => o.Id == orderId);

// ✅ Explicit - conditional loading
var order = await context.Orders
    .FirstOrDefaultAsync(o => o.Id == orderId);

if (order.Status == OrderStatus.Confirmed)
{
    await context.Entry(order)
        .Collection(o => o.LineItems)
        .LoadAsync();
}

// ❌ Lazy - hidden queries, N+1 risk
foreach (var item in order.LineItems) // hidden query
{
    Console.WriteLine(item.Product.Name); // hidden query per item
}
```

## Summary

Use a projection or eager loading when the query already knows which related data it needs.
Use explicit loading when the decision depends on state discovered after the entity is loaded.
Lazy loading hides network calls behind property access, so enable it only with query-count visibility and a deliberate reason.

## Frequently asked questions

### What is the difference between lazy loading and eager loading in EF Core?

Eager loading fetches related data upfront in the same query using Include. Lazy loading defers the query until the navigation property is first accessed, issuing a separate query at that moment.

### Why is lazy loading considered bad practice in EF Core?

It hides database queries behind property access, which makes N+1 query problems easy to introduce and hard to spot. A loop touching a navigation property can silently fire hundreds of queries.

### When should I use explicit loading in EF Core?

When you only need related data conditionally, based on runtime logic. You load the parent first, then call LoadAsync on specific collections or references only when needed.

### How do I enable lazy loading in EF Core?

Install Microsoft.EntityFrameworkCore.Proxies, call UseLazyLoadingProxies in your DbContext options, and make navigation properties virtual. Alternatively, inject ILazyLoader into entities for proxy-free lazy loading.

### Do filtered includes reduce the amount of data loaded?

Yes. Since EF Core 5, you can apply Where, OrderBy, Skip, and Take inside Include, so only the matching related entities are fetched instead of the entire collection.
