Back to: ASP.NET Core Web API Tutorials
Lazy Loading in Entity Framework Core
In any real-world application, data does not exist in isolation; entities are connected. A customer places orders, an order contains products, and each product belongs to a category. Entity Framework Core helps us model these relationships using Navigation Properties (in C# Models) and Foreign Keys (in the Database Tables).
However, fetching all related data every time can be inefficient. That’s where Lazy Loading comes in. It allows EF Core to defer (delay) loading related data until it is needed, improving performance and resource efficiency. This is a continuation of our previous article, where we discussed Relationships in Entity Framework Core.
What Do You Mean by Related Entities in Entity Framework Core?
Related Entities in EF Core are entities that are connected through Relationships, such as one-to-one, one-to-many, or many-to-many associations. They represent real-world dependencies between business objects. Relationships are expressed in two layers:
- At the code level, use Navigation Properties that point to related entities or collections.
- At the database level, use Foreign Keys that enforce referential integrity.
A Related Entity could be:
- A Single Object in a one-to-one relation (e.g., Customer → Profile).
- A Collection of Objects in a one-to-many relation (e.g., Customer → Orders).
- A Bridged Relationship in a many-to-many relation (e.g., Order ↔ Product through OrderItem).
Example – Real-World Understanding
In our E-Commerce project:
- A Customer can have multiple Orders (one-to-many).
- A Customer can have multiple Addresses (one-to-many).
- A Customer has exactly one Profile (one-to-one).
- Each Order can contain multiple Products, and a product can appear in multiple orders (many-to-many).
EF Core uses these defined relationships to manage automatically joins, enforce constraints, maintain data consistency during query execution, and save operations. For a better understanding, please have a look at the following Customer Entity. In this case, the Profile, Address, and Order entities are related entities of the Customer Entity.

Here, Customer acts as the central entity (Principal Entity).
- Profile is linked through a one-to-one relation; every customer has exactly one profile and vice versa.
- Address follows a one-to-many relation; one customer can maintain several addresses (Home, Office, etc.).
- Order is also a one-to-many relation; one customer can place multiple orders.
How Many Ways Can We Load Related Entities in Entity Framework Core?
Entity Framework Core provides three mechanisms to load related data, each serving a different purpose depending on the use case.
Eager Loading
- Eager Loading retrieves both the main entity and its related data in a single query.
It is implemented using .Include() and .ThenInclude() methods. - Use Eager Loading when the related data is always required, such as in API responses that display complete object graphs.
Lazy Loading
- Lazy Loading defers loading related entities until their navigation property is accessed.
Only when you reference the property in code does EF Core issue a query to retrieve it. - Use Lazy Loading when the related data might or might not be needed, to avoid unnecessary joins and reduce the initial query size.
Explicit Loading
- Explicit Loading gives complete manual control. The related data is not loaded automatically; you explicitly load it later using methods like Entry().Reference().LoadAsync() for single entities (e.g., Profile) or Entry().Collection().LoadAsync() for collections (e.g., Orders).
- Use this method when you need selective control over which relationships are fetched after the main entity has been retrieved.
What Is Lazy Loading in Entity Framework Core?
Lazy Loading is the technique where EF Core loads related entities only when you access them, not when the main entity is first retrieved.
When you fetch an entity such as Order, EF Core initially loads only that table’s data. If later you access order.Customer, EF Core automatically triggers a new SQL query behind the scenes to retrieve the customer’s details.
This technique minimizes the initial data load and improves response time when not all related entities are required. However, if used excessively (for example, within loops), it can generate too many database calls, leading to the well-known N+1 Query Problem.

Here,
- When we query a Customer using FindAsync(id), EF Core only loads the Customer entity, none of its related data (Profile, Addresses, or Orders) is fetched at this point.
- When we later access the navigation property customer.Profile, EF Core automatically executes a new SQL query in the background to load the related Profile data.
- Similarly, when we access customer.Addresses, EF Core again fires a separate SELECT query to retrieve all related Addresses for that Customer.
- Finally, when we access customer.Orders, EF Core issues another SQL query to load the related Orders data.
This is the behavior of Lazy Loading. EF Core loads related entities only when their navigation properties are accessed for the first time, not when the main entity is initially retrieved.
How to Implement Lazy Loading in EF Core?
EF Core doesn’t enable Lazy Loading automatically; it must be configured. Lazy Loading requires EF Core to generate runtime proxy classes that override navigation properties and issue background SQL queries when those properties are accessed. To implement Lazy Loading using EF Core, we need to follow the steps below:
- Install the Required Proxy Package for Lazy Loading
- Enable Proxies in Program.cs
- Mark Navigation Properties as Virtual
Step 1: Install the Required Proxy Package for Lazy Loading
Please install the Microsoft.EntityFrameworkCore.Proxies NuGet package using the Package Manager Console by executing the following command. This package enables EF Core to create proxy classes for lazy-loading navigation properties.
- Install-Package Microsoft.EntityFrameworkCore.Proxies
Step 2: Enable Proxies in Program.cs
Once the Proxy Package is installed, enable lazy-loading proxies while configuring the DbContext. This needs to be done by calling the UseLazyLoadingProxies() method as follows.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
.UseLazyLoadingProxies()); //Enable Lazy Loading
Note: The UseLazyLoadingProxies() method internally sets the LazyLoadingEnabled property to true by default, which enables Lazy loading. You can also pass true or false to this method to enable or disable Lazy loading in your application.
Step 3: Mark Navigation Properties as virtual
EF Core can create proxies only for virtual navigation properties. All entities must declare navigation properties as virtual. Then EF Core will create proxy classes that override these virtual properties and trigger lazy loading when they are accessed.
Note: We have already marked all the Navigation Properties as virtual so that Lazy Loading can be activated immediately.
That’s all. Once these three steps are complete, EF Core automatically loads related data the first time a navigation property is accessed in code.
Creating Customer Controller to Demonstrate Lazy Loading
Create an API Empty Controller named CustomerController within the Controllers folder and then copy and paste the following code. The following controller demonstrates Lazy Loading. Please read the inline comments for a better understanding.
using ECommerceApp.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CustomerController : ControllerBase
{
private readonly AppDbContext _context;
public CustomerController(AppDbContext context)
{
_context = context;
}
// ======================================================================
// GET: api/customer/{id}
// PURPOSE:
// Demonstrates Lazy Loading in EF Core.
// Only the main Customer entity is retrieved initially.
// When navigation properties (Profile, Addresses, Orders) are accessed,
// EF Core automatically fires separate SQL queries behind the scenes.
// ======================================================================
[HttpGet("{id:int}")]
public async Task<IActionResult> GetCustomerById(int id)
{
// STEP 1: Fetch only the Customer entity by primary key.
// Lazy loading ensures that related entities are NOT loaded at this point.
var customer = await _context.Customers.FindAsync(id);
if (customer == null)
return NotFound($"Customer with ID {id} not found.");
// ==================================================================
// STEP 2: Accessing navigation properties below triggers
// automatic SQL queries via EF Core’s proxy objects.
// Each navigation property is loaded only when first accessed.
// ==================================================================
// Load Profile automatically when accessed
// EF triggers a SELECT for Profile table
var profile = customer.Profile;
// Load Addresses automatically when accessed
// EF triggers a SELECT for Addresses table
var addresses = customer.Addresses;
// Load Orders automatically when accessed
// EF triggers a SELECT for Orders table
var orders = customer.Orders;
// ==================================================================
// STEP 3: Construct an Anonymous Object with all details.
// Related entities are now fully available because EF Core
// has executed necessary SQL queries automatically.
// ==================================================================
var result = new
{
customer.CustomerId,
customer.Name,
customer.Email,
customer.Phone,
Profile = profile == null ? null : new
{
profile.DisplayName,
profile.Gender,
DateOfBirth = profile.DateOfBirth.ToString("yyyy-MM-dd")
},
Addresses = addresses?.Select(a => new
{
a.AddressId,
a.Line1,
a.Street,
a.City,
a.PostalCode,
a.Country
}).ToList(),
Orders = orders?.Select(o => new
{
o.OrderId,
OrderDate = o.OrderDate.ToString("yyyy-MM-dd HH:mm:ss"),
o.TotalAmount,
Status = o.OrderStatus?.Name, // Lazy load triggers OrderStatus if accessed
Items = o.OrderItems?.Select(i => new
{
i.ProductId,
ProductName = i.Product.Name, // Triggers lazy load of Product
Category = i.Product.Category.Name, // Triggers lazy load of Category
i.Quantity,
i.UnitPrice
})
}).ToList()
};
// ==================================================================
// STEP 4: Return structured anonymous object.
// At this point, EF Core has loaded all the required related data
// lazily and efficiently, only when it was accessed.
// ==================================================================
return Ok(result);
}
}
}
Code Explanation
- EF Core loads the Customer entity first.
- Each navigation property (Profile, Addresses, Orders) triggers a separate SQL query the first time it’s accessed.
- Subsequent access to the same property does not cause another query because EF Core caches the result in the current DbContext instance.
Run the application, access the endpoint, and then verify the EF Core Logs. You should see separate SQL Queries for loading the Related data.
Key Points to Remember:
- With Lazy Loading enabled in EF Core, it will automatically load data from the database when we access a related entity for the first time.
- The loaded data is then stored in the context’s change tracker (in memory).
- If we reaccess the same related entity within the same DbContext instance, the data is fetched from in-memory rather than requerying the database.
- This avoids redundant database calls and improves performance for subsequent access to the same entity.
How Lazy Loading Works Internally with EF Core?
So, when Lazy Loading is enabled through proxies:
- EF Core Creates Dynamic Proxy Classes that inherit from your entity classes.
- These proxies override the virtual navigation properties.
- When you access a navigation property, EF Core:
-
- Checks if it’s already loaded.
- If not, it executes a new SQL query in the background to load the data.
-
- Once loaded, EF caches the related data in memory to prevent re-querying.
Let’s take the Customer entity as an example:
namespace ECommerceApp.Models
{
public class Customer : BaseAuditableEntity
{
public int CustomerId { get; set; }
public string Name { get; set; } = null!;
public string Email { get; set; } = null!;
public string Phone { get; set; } = null!;
public virtual Profile? Profile { get; set; }
public virtual ICollection<Address>? Addresses { get; set; }
public virtual ICollection<Order>? Orders { get; set; }
}
}
The EF Core generates a proxy class at runtime that overrides the navigation properties, such as Profile. The proxy class checks whether the Profile entity is already loaded; if not, it triggers a database query to load it. The following is a conceptual view of what happens under the hood:
public class CustomerProxy : Customer
{
private Profile _profile;
public override Profile Profile
{
get
{
if (_profile == null)
{
// EF Core issues SQL automatically to load the profile
_profile = EFCoreLazyLoader.LoadRelatedEntity<Profile>(this.CustomerId);
}
return _profile;
}
set => _profile = value;
}
}
How to Disable Lazy Loading in EF Core?
There are several ways to disable lazy loading, depending on your requirements.
Remove proxy configuration from your DbContext setup:
// options.UseLazyLoadingProxies(); // Comment or remove this line
Disable it programmatically at runtime:
_context.ChangeTracker.LazyLoadingEnabled = false;
Remove the virtual keyword from navigation properties if you never intend to use lazy loading.
Disabling lazy loading is useful for debugging, preventing circular references, or optimizing performance when eager loading is preferred.
Programmatically Enabling and Disabling Lazy Loading in EF Core
You can toggle lazy loading at runtime:
- Disable temporarily: _context.ChangeTracker.LazyLoadingEnabled = false;
- Re-enable when needed: _context.ChangeTracker.LazyLoadingEnabled = true;
This is useful when you want complete control over when lazy loading happens, for example, enabling it only for read operations and disabling it during batch inserts or bulk updates. For a better understanding, please modify the CustomerController as follows:
using ECommerceApp.Data;
using Microsoft.AspNetCore.Mvc;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CustomerController : ControllerBase
{
private readonly AppDbContext _context;
public CustomerController(AppDbContext context)
{
_context = context;
}
// ============================================================
// GET: api/customer/lazyloading-demo/{id}
// PURPOSE:
// Demonstrates how Lazy Loading behaves when it is disabled and then
// enabled programmatically within the same action method.
// This allows you to observe the difference in EF Core behavior
// for navigation property loading under both states.
// ============================================================
[HttpGet("lazyloading-demo/{id:int}")]
public async Task<IActionResult> DemonstrateLazyLoadingBehavior(int id)
{
// STEP 1: Retrieve only the main Customer entity.
// At this stage, no related entities are loaded (Profile, Addresses, Orders).
var customer = await _context.Customers.FindAsync(id);
if (customer == null)
return NotFound($"Customer with ID {id} not found.");
// ========================================================
// STEP 2: DISABLE Lazy Loading
// This prevents EF Core from automatically querying related entities
// when navigation properties are accessed.
// ========================================================
_context.ChangeTracker.LazyLoadingEnabled = false;
// Accessing navigation properties now will NOT trigger SQL queries.
var profileWhenDisabled = customer.Profile; // Will remain NULL
var addressesWhenDisabled = customer.Addresses; // Will remain NULL
var ordersWhenDisabled = customer.Orders; // Will remain NULL
// ========================================================
// STEP 3: ENABLE Lazy Loading
// Once re-enabled, EF Core automatically issues SELECT queries
// when navigation properties are accessed.
// ========================================================
_context.ChangeTracker.LazyLoadingEnabled = true;
// Accessing navigation properties now triggers Lazy Loading internally.
var profileWhenEnabled = customer.Profile; // EF fires SQL for Profile
var addressesWhenEnabled = customer.Addresses; // EF fires SQL for Addresses
var ordersWhenEnabled = customer.Orders; // EF fires SQL for Orders
// ========================================================
// STEP 4: Prepare Response for Comparison
// We demonstrate both states side by side to visualize the difference.
// ========================================================
var result = new
{
LazyLoadingInitiallyDisabled = new
{
Message = "Lazy Loading Disabled — Related entities are NOT fetched automatically.",
Profile = profileWhenDisabled == null ? "NULL (Not Loaded)" : "Loaded",
Addresses = addressesWhenDisabled == null ? "NULL (Not Loaded)" : "Loaded",
Orders = ordersWhenDisabled == null ? "NULL (Not Loaded)" : "Loaded"
},
LazyLoadingAfterEnabled = new
{
Message = "Lazy Loading Enabled — Related entities are automatically fetched when accessed.",
Profile = profileWhenEnabled == null ? "NULL" : profileWhenEnabled.DisplayName,
AddressCount = addressesWhenEnabled?.Count,
OrderCount = ordersWhenEnabled?.Count
}
};
// =======================================================
// STEP 5: Return Structured Response
// This makes it crystal clear how enabling and disabling Lazy Loading
// impacts data retrieval within the same DbContext instance.
// =======================================================
return Ok(result);
}
}
}
When you access the above endpoint, you will get the following result:

When to Use Lazy Loading in EF Core
Lazy Loading is beneficial when related data is not always required, and you want to minimize the initial database load. It simplifies object navigation since related data can be accessed through properties. Use Lazy Loading when:
- Data access patterns are unpredictable.
- The UI or process only needs certain relationships occasionally.
- You want to maintain simple, object-oriented code without explicitly calling .Include() everywhere.
Avoid Lazy Loading when:
- Building high-performance APIs where all required data should be fetched in a single query.
- Working with large datasets where multiple queries could overwhelm the database.
- Serializing entities directly, since proxies may cause circular references.
Drawbacks of Lazy Loading
While convenient, Lazy Loading introduces some challenges:
- N + 1 Query Problem: Accessing related data in a loop can result in dozens or hundreds of queries, one for each parent entity.
- Performance Overhead: Creating proxy classes and intercepting property access adds a slight runtime cost.
- Serialization Issues: Proxies maintain bidirectional references, which can cause infinite loops during JSON serialization.
- Debugging Difficulty: Since EF Core silently fires queries behind the scenes, it can be harder to trace performance bottlenecks.
Example to Understand N + 1 Query Problem and Serialization Issues with Lazy Loading
When working with Lazy Loading in Entity Framework Core, two common issues often occur if we’re not careful with how related entities are fetched and returned from the API: the N+1 Query Problem and the JSON Serialization Issue. Let’s first understand what these problems are, then we’ll go through a complete example and its optimized solution.
N + 1 Query Problem
The N + 1 Query Problem is a performance issue that arises when Lazy Loading triggers one database query for the primary entity, followed by an additional query for each related entity. For example, when retrieving all customers and accessing each customer’s profile, EF Core executes:
- 1 query to load all customers, and
- N additional queries (one per profile).
If there are 100 customers, EF Core executes 101 SQL queries, which is why it’s called the N+1 Problem. This can drastically reduce performance on larger datasets because it leads to excessive database round-trips.
JSON Serialization Issue
The JSON Serialization Issue occurs because Lazy Loading uses runtime-generated proxy classes that maintain bidirectional references between entities.
For instance, a Customer contains Orders, and each Order refers back to the same Customer. When such proxy objects are serialized into JSON, the serializer recursively traverses the object graph, Customer → Orders → Customer → Orders → … — leading to infinite loops or runtime exceptions during serialization.
This issue is especially common when returning EF entities directly from Web APIs without projection.
Example: Demonstrating Both Problems
The following controller intentionally uses Lazy Loading to demonstrate both the N+1 Query Problem and the Serialization Issue.
using ECommerceApp.Data;
using Microsoft.AspNetCore.Mvc;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CustomerController : ControllerBase
{
private readonly AppDbContext _context;
public CustomerController(AppDbContext context)
{
_context = context;
}
// ================================================================
// GET: api/customer/nplusone-demo
// PURPOSE:
// Demonstrates the "N + 1 Query Problem" caused by Lazy Loading in EF Core.
// EF Core first loads all Customers, then issues one additional SQL query
// for each Customer when accessing their related Profile.
// ================================================================
[HttpGet("nplusone-demo")]
public IActionResult DemonstrateNPlusOneProblem()
{
// STEP 1: Load all customers from the database (1 SQL query executed here)
var customers = _context.Customers.ToList();
// STEP 2: Access each customer's Profile property
// Lazy Loading triggers a NEW SQL query per customer.
// So if there are 100 customers, 100 additional SELECT queries are fired!
var result = customers.Select(c => new
{
c.CustomerId,
c.Name,
ProfileName = c.Profile?.DisplayName // Triggers Lazy Loading individually
}).ToList();
// STEP 3: Return the results.
// This demonstrates the performance issue caused by multiple round-trips.
return Ok(new
{
Message = "Demonstration of N + 1 Query Problem. One query per Customer is fired when accessing Profile.",
TotalCustomers = result.Count,
ResultPreview = result.Take(5) // Show first 5 for demonstration
});
}
// ================================================================
// GET: api/customer/serialization-demo
// PURPOSE:
// Demonstrates the Serialization Issue with Lazy Loaded entities.
// Lazy-loaded proxy objects contain circular navigation references,
// which can cause infinite loops during JSON serialization.
// ================================================================
[HttpGet("serialization-demo")]
public IActionResult DemonstrateSerializationIssue()
{
// STEP 1: Retrieve all customers
// EF Core will create proxy objects for Lazy Loading.
var customers = _context.Customers.ToList();
// STEP 2: Return the proxy entities directly to the serializer.
// This is dangerous because:
// - A Customer references Orders
// - Each Order references the same Customer again
// This creates a circular reference (Customer -> Order -> Customer -> ...)
// When the JSON serializer tries to traverse this object graph,
// it may fall into infinite recursion and throw a runtime exception.
return Ok(customers);
}
}
}
What Happens Here
- In the nplusone-demo, EF Core fires one query to load all customers and one query per Profile, causing unnecessary round-trips.
- In the serialization-demo, EF Core returns proxy objects with circular references. The serializer recursively traverses relationships, leading to infinite loops or exceptions.
Example: Fixing Both Problems
- The N+1 problem is fixed using Eager Loading (Include()).
- The Serialization Issue is fixed by projecting data into anonymous objects (or DTOs) before returning.
The following code shows how to solve both issues effectively:
using ECommerceApp.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ECommerceApp.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class CustomerController : ControllerBase
{
private readonly AppDbContext _context;
public CustomerController(AppDbContext context)
{
_context = context;
}
// =========================================================
// GET: api/customer/nplusone-solution
// PURPOSE:
// Solves the "N + 1 Query Problem" by using Eager Loading with .Include().
// Eager Loading fetches all required related data in a single SQL query.
// Although we are not returning related data here, this ensures EF Core
// does not issue one query per related entity internally.
// =========================================================
[HttpGet("nplusone-solution")]
public IActionResult FixNPlusOneProblem()
{
// STEP 1: Use Eager Loading to include related entities in one go.
// Even though we will not return Profile data, EF Core loads it efficiently
// in the same query — preventing N additional round trips.
var customers = _context.Customers
.Include(c => c.Profile)
.AsNoTracking()
.ToList();
// STEP 2: Project only Customer data (no related data returned).
// We are not exposing navigation properties to keep the response lightweight.
var result = customers.Select(c => new
{
c.CustomerId,
c.Name,
c.Email,
c.Phone
}).ToList();
// STEP 3: Return a clean, single-query result.
return Ok(new
{
Message = "N + 1 Query Problem solved using Eager Loading (single optimized query).",
TotalCustomers = result.Count,
Data = result.Take(5) // Show first 5 records for quick verification
});
}
// ==========================================================
// GET: api/customer/serialization-solution
// PURPOSE:
// Solves the "Serialization Issue" by projecting Lazy Loaded entities
// into DTOs or anonymous objects before returning them.
// This prevents circular reference loops between Customer ↔ Orders.
// ==========================================================
[HttpGet("serialization-solution")]
public IActionResult FixSerializationIssue()
{
// STEP 1: Retrieve all customers (Lazy Loading may still be ON).
// By projecting below, we avoid serializing EF Core proxy objects directly.
var customers = _context.Customers
.AsNoTracking()
.Select(c => new
{
c.CustomerId,
c.Name,
c.Email,
c.Phone
})
.ToList();
// STEP 2: Return clean JSON without circular references.
// Since we are returning simple anonymous objects, the serializer
// never encounters navigation properties or proxy-generated loops.
return Ok(new
{
Message = "Serialization Issue solved using DTO/Anonymous projection. No circular references.",
TotalCustomers = customers.Count,
Data = customers.Take(5) // Show first few records for demo
});
}
}
}
Lazy Loading in EF Core is a powerful feature that provides flexibility and improves performance when related data is not always needed. It allows data to be fetched only on demand, leading to optimized queries and a cleaner object model.
However, developers must understand its internal behaviour and trade-offs. Overuse of Lazy Loading can lead to multiple hidden queries, serialization issues, and performance bottlenecks. Therefore, the ideal approach is to use Eager Loading for predictable data needs and Lazy Loading for dynamic, infrequent relationships. In the next article, I will discuss Eager Loading in EF Core.
