I remember the first time AutoInclude()
burned me in production. We had an Order
entity, and someone thought it’d be convenient to always load the Customer
and OrderItems
. Local tests were snappy. But once we deployed, a simple dashboard API that just needed to show order statuses started timing out. The database CPU was pegged at 100%.
The culprit? A seemingly harmless configuration was forcing massive, unnecessary joins on every single query.
Here’s why AutoInclude()
is a performance trap and what you should do instead.
What’s actually happening under the hood?
When you use AutoInclude()
in your OnModelCreating
configuration, you’re telling EF Core to always run a JOIN
for that relationship. Every time. No exceptions.
// In your DbContext's OnModelCreating method
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Navigation(o => o.Customer)
.AutoInclude();
modelBuilder.Entity<Order>()
.Navigation(o => o.OrderItems)
.AutoInclude();
}
With this in place, any query for an Order
will automatically fetch the Customer
and the list of OrderItems
.
The Production Nightmare: Silent and Deadly Joins
The real problem with AutoInclude()
is that it hides the database cost. The performance penalty isn’t visible in your application code; it’s buried deep in the model configuration.
When your code looks this simple:
var orders = await _context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
You’d expect a simple SELECT
on the Orders
table. But because of AutoInclude()
, EF Core generates something far more expensive:
SELECT o.*, c.*, oi.*
FROM Orders o
LEFT JOIN Customers c ON o.CustomerId = c.Id -- This might be unnecessary
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId -- And this is definitely overkill
WHERE o.Status = 1
This one burned me once in production. We had a background job that fetched pending orders to process payments. It didn’t need customer or item details, but AutoInclude()
was pulling megs of data for thousands of orders, slowing the whole job to a crawl.
Bloated API Responses and Wasted Bandwidth
This gets even worse in your APIs. Let’s say you have two endpoints:
GET /api/orders
- A lightweight endpoint for a dashboard, just needs order IDs and statuses.GET /api/orders/{id}
- The full order details page, needs everything.
With AutoInclude()
, both endpoints fetch the exact same massive dataset. The dashboard endpoint, which should be fast and light, ends up transferring huge amounts of data that the client just throws away. Even a projection with Select()
won’t save you from the initial database hit.
// This query looks innocent...
public async Task<List<int>> GetPendingOrderIds()
{
return await _context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.Select(o => o.Id) // ...but the JOINs already happened!
.ToListAsync();
}
The serialization overhead is another sneaky cost. That simple dashboard response can balloon from a few kilobytes to megabytes.
Without AutoInclude (2KB response):
{
"orders": [
{ "id": 1, "status": "pending", "total": 99.99 }
]
}
With AutoInclude (45KB response):
{
"orders": [
{
"id": 1,
"status": "pending",
"total": 99.99,
"customer": { /* full customer object */ },
"orderItems": [ /* array of full order item objects */ ]
}
]
}
The Hard Numbers
Don’t just take my word for it. Here are some real-world numbers from an e-commerce API I worked on, comparing AutoInclude()
to explicit loading for a few common scenarios.
Scenario | AutoInclude Enabled | Manual Include/Projection |
---|---|---|
List 100 Orders (IDs only) | 380ms, 2.8MB payload | 45ms, 95KB payload |
Get Order Details (needs all data) | 385ms, 2.8MB payload | 385ms, 2.8MB payload |
Simple Order Status Update | 320ms (includes fetching) | 35ms (no fetching) |
As you can see, AutoInclude()
makes the simple queries just as slow as the most complex ones. It introduces a massive, constant performance tax on every operation.
Better, Safer Alternatives
So how do you fix it? You take back control by being explicit about what data you load and when.
First, rip out the AutoInclude()
configuration from your OnModelCreating
method.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Good riddance.
// modelBuilder.Entity<Order>()
// .Navigation(o => o.Customer)
// .AutoInclude();
}
Now, use one of these far safer patterns.
1. Explicit Include()
Per Query (The Standard Way)
This is the most common and straightforward approach. You specify exactly what you need for each query.
// Light query for a dashboard (no includes)
var orderSummaries = await _context.Orders
.Where(o => o.IsActive)
.ToListAsync();
// Heavy query for a details screen (explicit includes)
var orderWithDetails = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.FirstAsync(o => o.Id == orderId);
Why it’s better: The cost of each query is obvious right where you write it. No hidden magic.
2. Projections with Select
(The High-Performance Way)
For read-heavy APIs, this is my go-to. Instead of pulling full entities, you shape the data into a DTO (Data Transfer Object) directly in your query. EF Core is smart enough to generate the most efficient SQL possible.
public async Task<OrderSummaryDto[]> GetOrderSummaries()
{
return await _context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // Only joins for the Name column
Status = o.Status,
Total = o.Total
})
.ToArrayAsync();
}
This generates beautiful, targeted SQL instead of a bloated SELECT *
. You get exactly the data you need and nothing more.
Here’s My Take
So, when should you use AutoInclude()
? Almost never in a production API.
Here’s when I’d consider it:
- Internal Admin Tools: If you have a simple back-office app with a small dataset where performance isn’t critical and nearly every view needs the same related data.
- Rapid Prototyping: When you’re just trying to get something working and plan to refactor it before it sees real traffic.
Here’s when I’d avoid it (which is most of the time):
- Customer-Facing APIs: Never. The performance cost is too unpredictable and high. Latency and bandwidth matter here.
- Any system with varied data access patterns: If one part of your app needs a lightweight
Order
and another needs the full object graph,AutoInclude()
is the wrong tool.
In production code, clarity and control trump convenience. AutoInclude()
offers a tempting shortcut, but it’s a trade-off that will almost always come back to bite you. Keep your data loading explicit.