Eager Loading vs Lazy Loading vs Explicit Loading in EF Core

In this article we going to learn the difference between Eager Loading, Lazy Loading, and Explicit Loading in EF Core — what each one does, when to use each, and what problems they solve.

When we query data in EF Core, we're usually not just fetching one table. We fetch an Order and we also need the Customer who placed it. We fetch a Blog and we need its Posts. How EF Core loads those related entities is what these three loading strategies are about.

Pick the wrong one and we end up with either way too many database queries (N+1 problem) or loading gigabytes of data we never use. Both hurt performance in different ways. Understanding these three strategies is one of the more important things to get right when building a .NET application with EF Core.

This tutorial shows how to:

  • Understand what Eager Loading is and how to use Include() and ThenInclude()
  • Understand Lazy Loading and how to enable it
  • Understand Explicit Loading and when it's useful
  • Identify and fix the N+1 query problem
  • Know which strategy to pick for different scenarios
  • See the actual SQL generated for each approach

The Setup — Models We'll Use

Throughout this article we'll work with three related models — Blog, Post, and Comment. This gives us a few levels of relationships to demonstrate all three loading strategies properly.

public class Blog
{
    public int BlogId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;

    public List<Post> Posts { get; set; } = new();
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public bool IsPublished { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;

    public List<Comment> Comments { get; set; } = new();
}

public class Comment
{
    public int CommentId { get; set; }
    public string Text { get; set; } = string.Empty;
    public string AuthorName { get; set; } = string.Empty;

    public int PostId { get; set; }
    public Post Post { get; set; } = null!;
}

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

    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
    public DbSet<Comment> Comments { get; set; }
}

What Happens Without Any Loading Strategy

Before looking at the three strategies, let's see what happens if we just query blogs and try to access posts without any loading configured.

var blogs = await _context.Blogs.ToListAsync();

foreach (var blog in blogs)
{
    Console.WriteLine(blog.Title);
    Console.WriteLine(blog.Posts.Count);  // Posts is empty — always 0
}

By default, EF Core does not load related data automatically. blog.Posts will be an empty list. No exception, no warning — just empty. This surprises a lot of beginners who expect the navigation property to be populated just because it's there.

We have to explicitly tell EF Core how to load related data. That's where the three strategies come in.


Step 1 : Eager Loading with Include()

Eager Loading loads related data as part of the initial query. Everything comes back in one or a few SQL queries. We tell EF Core upfront what related data we need.

var blogs = await _context.Blogs
    .Include(b => b.Posts)
    .ToListAsync();

EF Core generates a JOIN query that fetches Blogs and Posts together :

SELECT b.BlogId, b.Title, b.Author, p.PostId, p.Title, p.Content, p.IsPublished, p.BlogId
FROM Blogs b
LEFT JOIN Posts p ON b.BlogId = p.BlogId
ORDER BY b.BlogId

Now blog.Posts is populated. One query, all the data we need.

ThenInclude for nested relationships

What if we also need Comments inside each Post? Chain ThenInclude() :

var blogs = await _context.Blogs
    .Include(b => b.Posts)
                .ThenInclude(p => p.Comments)
    .ToListAsync();

This loads Blogs → Posts → Comments all in one go. EF Core generates additional JOINs or separate queries depending on the relationship cardinality.

Multiple includes

Load multiple unrelated navigation properties on the same entity :

var posts = await _context.Posts
    .Include(p => p.Blog)
    .Include(p => p.Comments)
    .ToListAsync();

Both Blog and Comments are loaded with the posts.

Filtered includes (EF Core 5+)

We can filter what gets included :

var blogs = await _context.Blogs
    .Include(b => b.Posts.Where(p => p.IsPublished))
    .ToListAsync();

Only published posts are loaded for each blog. Unpublished posts are not fetched at all.

When to use Eager Loading

Use it when we know upfront what related data we'll need. If our API endpoint always returns a Blog with its Posts, eager load them. It's predictable, the SQL is clear, and we avoid multiple round trips to the database.


Step 2 : Lazy Loading

Lazy Loading loads related data automatically when we access a navigation property — but only at that moment, not during the initial query. The first time we touch blog.Posts, EF Core goes back to the database and fetches the posts.

Enable Lazy Loading

Lazy Loading requires a proxy library. Install it :

dotnet add package Microsoft.EntityFrameworkCore.Proxies

Enable it in our DbContext configuration :

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseLazyLoadingProxies());

Our navigation properties must be virtual for the proxy to intercept them :

public class Blog
{
    public int BlogId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;

    public virtual List<Post> Posts { get; set; } = new();  // virtual
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public bool IsPublished { get; set; }

    public int BlogId { get; set; }
    public virtual Blog Blog { get; set; } = null!;  // virtual

    public virtual List<Comment> Comments { get; set; } = new();  // virtual
}

Now we can query blogs without Include and the posts still load when accessed :

var blogs = await _context.Blogs.ToListAsync();

foreach (var blog in blogs)
{
    // EF Core fires a separate SQL query here to load posts
    Console.WriteLine(blog.Posts.Count);
}

The N+1 Problem — The Danger of Lazy Loading

This is where lazy loading causes real trouble. Say we have 50 blogs. We query them (1 query). Then in a loop we access blog.Posts for each one — EF Core fires 50 separate queries. That's 51 queries total for what could have been 1 or 2.

This is the N+1 query problem. 1 query for the list + N queries for each item's related data.

Here's how it looks :

// This looks innocent but fires 51 SQL queries for 50 blogs
var blogs = await _context.Blogs.ToListAsync();  // 1 query

foreach (var blog in blogs)
{
    foreach (var post in blog.Posts)  // 1 query per blog = 50 queries
    {
        Console.WriteLine(post.Title);
    }
}

In development with a small dataset this feels fine. In production with thousands of records, this kills performance.

Also — Lazy Loading requires an open DbContext when the navigation property is accessed. In ASP.NET Core with Scoped DbContext this is usually fine within a request. But if we serialize entities to JSON after the DbContext is disposed, we'll get an ObjectDisposedException.

When to use Lazy Loading

Honestly, use it carefully. It's convenient for rapid development and exploration. But in a production API, we almost always know what data we need upfront. Eager Loading gives us more control and avoids the N+1 trap.

Lazy Loading makes more sense for desktop or console applications where we're working interactively and the DbContext stays open.


Step 3 : Explicit Loading

Explicit Loading gives us manual control over when to load related data. We load the parent entity first, then explicitly tell EF Core to load specific navigation properties at a later point in our code.

// Load blog without any related data
var blog = await _context.Blogs
    .FirstOrDefaultAsync(b => b.BlogId == 1);

if (blog == null) return;

// Explicitly load Posts when we need them
await _context.Entry(blog)
    .Collection(b => b.Posts)
    .LoadAsync();

Console.WriteLine(blog.Posts.Count);  // Posts are now loaded

For a reference navigation (single object, not a collection) :

var post = await _context.Posts.FirstOrDefaultAsync(p => p.PostId == 1);

// Explicitly load the Blog that owns this post
await _context.Entry(post!)
    .Reference(p => p.Blog)
    .LoadAsync();

Console.WriteLine(post!.Blog.Title);

Explicit Loading with Query

We can also filter what gets loaded explicitly :

var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.BlogId == 1);

// Load only published posts
await _context.Entry(blog!)
    .Collection(b => b.Posts)
    .Query()
    .Where(p => p.IsPublished)
    .LoadAsync();

The .Query() gives us an IQueryable that we can filter, order, or limit before loading. Very useful when we want related data but only a subset of it.

Get Count Without Loading

Explicit Loading lets us count related data without loading all of it :

var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.BlogId == 1);

var publishedPostCount = await _context.Entry(blog!)
    .Collection(b => b.Posts)
    .Query()
    .Where(p => p.IsPublished)
    .CountAsync();

// Posts collection is NOT loaded — just the count was fetched
Console.WriteLine($"Published posts: {publishedPostCount}");

This is something neither Eager Loading nor Lazy Loading can do cleanly — fetching aggregate information about related data without loading all the related entities into memory.

When to use Explicit Loading

Use it when the decision of whether to load related data depends on something that happens after the initial query. For example — load an order, check its status, and only if it's "Pending" load the order items for processing.

var order = await _context.Orders.FindAsync(orderId);

if (order?.Status == "Pending")
{
    await _context.Entry(order)
        .Collection(o => o.Items)
        .LoadAsync();

    // Process items
}
// If status is not Pending, items are never loaded — saves a query

Side by Side Comparison

Here's a direct comparison of all three on the same query scenario — fetch a blog and its posts :

Eager Loading — one query, both tables joined :

var blog = await _context.Blogs
    .Include(b => b.Posts)
    .FirstOrDefaultAsync(b => b.BlogId == 1);
// 1 SQL query with JOIN

Lazy Loading — two queries, second fires automatically on access :

var blog = await _context.Blogs
    .FirstOrDefaultAsync(b => b.BlogId == 1);
// 1 SQL query - posts NOT loaded

var count = blog.Posts.Count;
// 1 more SQL query fires automatically here

Explicit Loading — two queries, second fires on our command :

var blog = await _context.Blogs
    .FirstOrDefaultAsync(b => b.BlogId == 1);
// 1 SQL query - posts NOT loaded

await _context.Entry(blog!).Collection(b => b.Posts).LoadAsync();
// 1 more SQL query fires here - our decision
var count = blog!.Posts.Count;

Which One to Use and When

Eager Loading — use this most of the time in web APIs. We know what data the endpoint needs, we include it upfront, we get predictable performance and clear SQL.

Lazy Loading — use with caution. Fine for exploratory work and desktop apps. In web APIs avoid it or we'll hit N+1 without realizing it. If we enable it, at least use a query logging tool to see how many queries are firing per request.

Explicit Loading — use it when loading is conditional. When we need to load related data based on logic that runs after the initial query. Also useful for loading just counts or aggregates without pulling all related data.


Detect N+1 Queries in Development

Enable EF Core query logging to see all SQL generated by our app. In appsettings.Development.json :

{
  "Logging": {
    "LogLevel": {
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Now every SQL query EF Core runs appears in our console output. If we see 50 identical SELECT queries in a loop, we've got an N+1 problem. Fix it by switching to Eager Loading with Include().


Summary

We learned the difference between Eager Loading, Lazy Loading, and Explicit Loading in EF Core. We covered :

  • Default behaviour — navigation properties are empty without any loading strategy
  • Eager Loading with Include() and ThenInclude() — loads related data in one query upfront
  • Filtered Includes in EF Core 5+ — load only a subset of related data
  • Lazy Loading — automatic loading on property access, requires virtual navigation properties and proxy library
  • The N+1 problem — 1 initial query plus N queries in a loop — how Lazy Loading causes it
  • Explicit Loading with Entry().Collection().LoadAsync() and Entry().Reference().LoadAsync()
  • Filtering and counting related data without loading it all using .Query()
  • When to use each — Eager for APIs, Lazy with caution for desktop, Explicit for conditional loading
  • Detecting N+1 with EF Core query logging

The general rule — default to Eager Loading in web APIs, use Explicit Loading when logic determines whether related data is needed, and treat Lazy Loading as a convenience that needs careful monitoring in production.

I hope you like this article...

Happy coding! 🚀

Post a Comment

0 Comments