Skip to main content

Command Palette

Search for a command to run...

EF Core Loading Strategies in ASP.NET Core: Eager, Lazy, and Explicit Loading โ€” Enterprise Decision Guide

Updated
โ€ข13 min read
EF Core Loading Strategies in ASP.NET Core: Eager, Lazy, and Explicit Loading โ€” Enterprise Decision Guide

The way your team loads related data in Entity Framework Core shapes production performance more than most architectural decisions ever will. Every navigation property access is a potential N+1 query. Every unresolved .Include() is a lazy-loaded trap waiting to fire under real traffic. Yet most .NET codebases adopt a loading strategy by default โ€” often by accident, not design.

EF Core gives you three ways to load related entities: eager loading, lazy loading, and explicit loading. Each is defensible in the right context. Each is a liability in the wrong one. The patterns covered here โ€” along with annotated, production-ready implementations that show how loading strategies interact with repository design, pagination, and caching โ€” are available on Patreon for developers who want the full picture.

Loading related data is one of those topics that looks straightforward on day one and quietly drives production incidents on day three hundred. Seeing how eager loading, lazy loading, and explicit loading each fit into a complete API codebase โ€” alongside pagination, repository abstraction, and query performance controls โ€” is exactly what Chapter 3 of the Zero to Production course covers, with source code that reflects how enterprise teams actually build and ship.

ASP.NET Core Web API: Zero to Production

This guide is for teams that want a clear, deliberate answer to: which strategy should we default to, when should we deviate, and what are the anti-patterns that will cost us at scale?


The Three Strategies at a Glance

Before getting into decisions, here is a plain-language summary of what each strategy does:

Eager loading uses .Include() and .ThenInclude() to join related entities in a single query at the point of the initial data fetch. The database does the work โ€” one round trip, everything comes back together.

Lazy loading defers the fetch until the navigation property is first accessed. When you touch order.Customer, EF Core issues a separate query at that moment. This happens transparently, which is both its appeal and its danger.

Explicit loading requires a deliberate call โ€” .Entry(entity).Reference(...).LoadAsync() or .Collection(...).LoadAsync() โ€” to load a related entity on demand. Nothing loads until you ask for it, explicitly, in code.


Eager Loading: When to Use It and When to Avoid It

When eager loading is the right call

Eager loading is the right default for the vast majority of enterprise API workloads. When the access patterns for related data are predictable โ€” you almost always need Order alongside OrderLines and Customer โ€” eager loading gives you a single, optimised query and no hidden round trips.

It is the only viable strategy inside read endpoints that project into DTOs. Once you call .Select(o => new OrderDto { ... }), EF Core cannot execute lazy loading even if the proxies are configured โ€” there is no tracked entity to intercept. Eager loading's .Include() is explicit, reviewable in code review, and directly traceable to the SQL it generates.

For APIs that serve paginated lists, eager loading combined with AsNoTracking() is the most performant read path. You are loading a bounded set of entities, projecting into a DTO, and discarding the change tracker entirely. There is no scenario where lazy loading competes with this.

When eager loading causes problems

Eager loading becomes a liability when the object graph is deep and the caller rarely needs all of it. Calling .Include(o => o.Lines).ThenInclude(l => l.Product).ThenInclude(p => p.Category).ThenInclude(c => c.SubCategories) on a root query for a list endpoint loads enormous amounts of data that most consumers will never use.

The second problem is Cartesian explosion. When you eagerly load multiple collection navigations from a single root, EF Core generates a query with multiple JOINs. If Order has 10 OrderLines and 5 Tags, the result set contains 50 rows for that single order โ€” and EF Core has to deduplicate them in memory. On large datasets, this quietly becomes your most expensive query.

The fix for deep graphs is usually split queries (.AsSplitQuery()) or a move to explicit loading for the less-frequently-accessed branches.


Lazy Loading: When to Use It and When to Avoid It

When lazy loading is the right call

Lazy loading is defensible in two scenarios. The first is command-side domain logic: when you have a complex aggregate root and you need access to specific navigation properties inside a business rule, lazy loading lets the domain model remain focused on behaviour rather than having to declare every possible data need at the query boundary.

The second scenario is exploratory or admin tooling โ€” dashboards, CMS panels, or internal reporting interfaces where performance is a second-order concern and developer ergonomics matter more. If your team builds an admin UI that needs to drill into several navigation paths that are hard to predict at query time, lazy loading makes the code readable.

Why lazy loading is almost always wrong for production APIs

Lazy loading requires virtual navigation properties and EF Core proxy infrastructure. It also requires that the DbContext remains in scope for the lifetime of the navigation access โ€” which is fine inside a transactional operation but dangerous across async boundaries, between DI scopes, and inside any serialisation path.

The real problem is the N+1 query: loading a list of 50 orders and then accessing order.Customer in a loop generates 51 queries โ€” one for the list, one per customer. Under light load this is invisible. Under production load it exhausts the connection pool.

The subtler problem is that lazy loading makes N+1 invisible in code review. The .Include() is not there. The only evidence is in query logs โ€” and most teams are not watching query logs with that level of precision until something is already on fire.

EF Core deliberately makes lazy loading inconvenient to enable: you must install Microsoft.EntityFrameworkCore.Proxies, call .UseLazyLoadingProxies(), and mark all navigation properties virtual. That friction is intentional. Treat it as a signal, not a setup problem to work around.

For a detailed breakdown of the EF Core performance pitfalls that lazy loading feeds โ€” including N+1, over-fetching, and connection pool exhaustion โ€” the EF Core Performance Tuning Checklist for High-Traffic APIs on this blog covers each one with diagnostics and fixes.


Explicit Loading: When to Use It and When to Avoid It

When explicit loading fits cleanly

Explicit loading is the right tool when you have a tracked entity that is already in memory and you need to conditionally load a navigation property based on runtime logic โ€” not at query time.

In a command handler that processes an order, you might only need to load order.PaymentMethod if the order is being processed through a specific payment flow. Loading it eagerly for every order wastes resources. Lazy loading would hide the intent. Explicit loading makes the conditional logic visible and controllable.

Explicit loading is also the correct pattern when re-loading a navigation after a command has modified related entities within the same DbContext scope โ€” you have a fresh state you need to observe before performing validation or publishing a domain event.

When explicit loading becomes noise

Explicit loading adds call sites. It is verbose compared to .Include() and requires the DbContext (or an abstraction over it) to be accessible in the location where the loading decision is made. In a clean architecture setup where domain logic cannot reference EF Core directly, explicit loading creates an awkward dependency.

It also does not compose well with LINQ projections. You cannot use .Entry(...).Reference(...).LoadAsync() inside a .Select() โ€” it operates on tracked entity instances only. For list endpoints projecting into DTOs, eager loading is always simpler.


Decision Matrix: Which Strategy for Which Scenario

Scenario Recommended Strategy Reason
Read endpoint, DTO projection, paginated list Eager loading + AsNoTracking() Predictable data need, no change tracking overhead
Read endpoint, full entity returned, shallow graph Eager loading Single query, no hidden round trips
Deep graph, only some branches always needed Eager loading + .AsSplitQuery() Avoids Cartesian explosion
Command handler, conditional related data Explicit loading Loads only what is needed, intent is visible
Domain logic after mutation, re-read required Explicit loading Controlled re-fetch of fresh state
Admin/backoffice UI, complex navigation needs Lazy loading (with caution) Ergonomics matter; scope is controlled
Production API list endpoint Never lazy loading N+1 risk, invisibility in code review
Serialisation path (e.g., API response) Never lazy loading Scope leaks and N+1 under load

Anti-Patterns That Cost You in Production

Anti-pattern 1: Lazy loading across async/await boundaries

Accessing a lazy-loaded navigation property after an await is one of the most reliable ways to produce an ObjectDisposedException in production. The DbContext may have been disposed when the continuation resumes, particularly when scope management is not perfectly aligned. The Most Common EF Core Mistakes in ASP.NET Core article covers several variants of this pattern and how to detect them before they reach production.

Anti-pattern 2: Eager loading everything on write paths

The loading strategy discussion mostly concerns reads, but some teams apply unrestricted .Include() chains on command-side queries too โ€” loading the full aggregate graph to update a single scalar property. Change tracking handles the update correctly, but the initial query fetches far more than necessary. On write paths, load only what the command needs to make its decision.

Anti-pattern 3: Using lazy loading as a performance shortcut

Some teams enable lazy loading proxies thinking it will reduce initial query time. It does reduce the first query โ€” but the total query count for a given request almost always increases. The performance of a request is the sum of all queries it issues, not the speed of the first one.

Anti-pattern 4: Applying one strategy globally without context

Many codebases configure lazy loading globally via UseLazyLoadingProxies() or assume eager loading with .Include() everywhere without evaluating per-query access patterns. The right approach is to make the decision per query, per endpoint, based on what that specific operation needs. Repository abstractions help here โ€” they let you define loading behaviour at the query construction site rather than relying on global configuration.


What Enterprise Teams Should Standardise

Default to eager loading with AsNoTracking() for all read endpoints. This is the safest, most performant default. It makes data dependencies explicit in code, reviewable in PRs, and visible in query logs.

Require explicit loading for conditional navigation access in command handlers. When a command needs related data only in certain code paths, explicit loading makes that condition visible and testable.

Treat lazy loading as opt-in, not opt-out. If your team decides to enable lazy loading for a specific bounded context โ€” admin tooling, legacy migration surface, internal reporting โ€” document the decision, limit its scope, and ensure that the DbContext lifetime is understood by everyone working in that context.

Log your queries in development. EF Core's SQL logging (via LogTo or structured Serilog integration) makes N+1 queries immediately visible. If you are writing an endpoint and the logs show the same query repeating for every item in a list, the loading strategy is wrong. Catch it in development, not in a production incident.

Validate at code review. Any new repository method or query that introduces lazy-loaded navigation access without an explicit Include() or EntryAsync().LoadAsync() call should be a code review comment. The absence of a loading declaration is not neutral โ€” it is a decision, and it should be a conscious one.


FAQ

What is the most common EF Core loading mistake in production?

The N+1 query caused by lazy loading in a list endpoint. It is invisible in code, only visible in logs, and scales linearly with result set size. A 50-item paginated list producing 51 database queries is the textbook case โ€” and it is more common in production than most teams realise.

Is lazy loading ever safe to use in an ASP.NET Core Web API?

Rarely. Lazy loading is safest in short-lived, synchronous, single-threaded contexts with controlled DbContext scope. ASP.NET Core APIs are async, DI-scoped, and heavily concurrent โ€” three conditions that increase the risk of scope disposal and N+1 runaway. Most enterprise API codebases should disable it and stick to eager or explicit loading.

Does eager loading always produce a single SQL query?

Not always. When you use .AsSplitQuery(), EF Core issues one query per included collection. Without it, multiple collection includes on the same query produce a JOIN with Cartesian rows. Both approaches are valid โ€” split queries reduce row duplication; single queries reduce round trips. The right choice depends on the cardinality of your navigation relationships.

When should I use .AsSplitQuery() vs the default single query?

Use .AsSplitQuery() when you include multiple collection navigations from the same root entity and the Cartesian product of the join would produce significantly more rows than entities. A good heuristic: if Order has a collection of Lines (average 10) and a collection of Tags (average 5), the single query produces 50 rows per order. At scale, this is costly. Split queries trade round trips for row count โ€” generally the better trade under high cardinality.

How does explicit loading interact with the repository pattern?

Explicit loading requires access to the DbContext or EF Core's ChangeTracker. In a strict repository pattern, the repository interface should expose a method that encapsulates the explicit load โ€” for example, LoadOrderLinesAsync(order) โ€” rather than exposing EF Core infrastructure directly. This keeps domain logic decoupled from persistence concerns while retaining full control over what is loaded and when.

Can I mix loading strategies across different queries in the same application?

Yes, and you should. The loading strategy is a per-query decision, not a global application setting. Your list endpoint for orders should use eager loading with AsNoTracking(). Your command handler that conditionally processes payments might use explicit loading. An admin detail page might use a deeper eager load with split queries. Each query should choose the strategy that matches its specific data access pattern.

Does AsNoTracking() affect loading strategy?

AsNoTracking() disables EF Core's change tracker for the query, which improves performance for read-only operations. However, it means you cannot use explicit loading after the query runs โ€” there is no tracked entity to call .Entry() on. Lazy loading with proxies also does not work with AsNoTracking(). For no-tracking queries, eager loading with .Include() is the only option.

More from this blog

C

Coding Droplets

244 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.