Entity Framework C# Optimization Techniques
Entity Framework Core is an incredibly powerful ORM — but with great power comes great responsibility.
If you’re not careful, a single LINQ statement can balloon into a massive SQL query, making performance terrible. I recently had this problem when building a SaaS tool. The problem was that some queries loaded a lot of related data, which very quickly resulted in out of memory exceptions. The problem was not easy to track down at first because the exceptions only happened on an Azure environment; everything worked fine on locally.
In this post, we’ll talk about four important concepts to keep your queries fast, predictable, and memory-friendly:
- Split Queries
- AsNoTracking
- Cartesian Explosion
- Projection with Automapper
The Problem: Cartesian Explosion
A Cartesian explosion happens when EF Core loads related collections in a single query using multiple JOINs.
For example:
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Comments)
.ToListAsync();Under the hood, EF Core might generate something like:
SELECT b.*, p.*, c.*
FROM Blogs b
LEFT JOIN Posts p ON b.Id = p.BlogId
LEFT JOIN Comments c ON b.Id = c.BlogIdIf a blog has 10 posts and 10 comments, you get 100 rows in memory for 1 blog — multiplying data unnecessarily.
This wastes memory, increases network payload, and slows everything down.
Split Queries to the Rescue
EF Core 5+ introduced Split Queries for Include().
Instead of doing one huge JOIN, EF Core can load data in multiple SQL queries:
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Comments)
.AsSplitQuery()
.ToListAsync();Now EF Core sends:
-- First query
SELECT * FROM Blogs;
-- Second query
SELECT * FROM Posts WHERE BlogId IN (...);
-- Third query
SELECT * FROM Comments WHERE BlogId IN (...);Advantages:
- Avoids Cartesian explosion
- Reduces duplicate data
- Smaller memory footprint
Disadvantages:
- More round-trips to the database
- Slightly higher latency if your DB is far away
Tip: You can enable split queries globally:
optionsBuilder.UseSqlServer(connectionString, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));AsNoTracking for Read-Only Queries
By default, EF Core tracks every entity it loads, so it can detect changes and persist them later.
But if you’re just reading data and not updating it, tracking is pure overhead.
var products = await context.Products
.AsNoTracking()
.ToListAsync();Benefits:
- Faster queries
- Lower memory usage
- No unnecessary
ChangeTrackerwork
You can combine AsNoTracking() with split queries for maximum read performance.
Projection with AutoMapper
If you don’t need the full entity, project directly into a DTO — skipping entity materialization entirely. While writing select statements to pull the columns you need is very tedious, Automapper takes care of that work for you. In the Nano Boilerplate, any repository method that returns mapped DTOs uses projection.
Instead of:
var blogs = await context.Blogs
.Include(b => b.Posts)
.AsNoTracking()
.ToListAsync();
var dtos = _mapper.Map<List<BlogDto>>(blogs); // Mapping in memoryYou can use AutoMapper’s ProjectTo:
var dtos = await _mapper.ProjectTo<BlogDto>(context.Blogs
.AsNoTracking()
.AsSplitQuery())
.ToListAsync();Why ProjectTo is better:
- Executes mapping at the database level — only needed columns are selected
- No unnecessary entity tracking
- Prevents Cartesian explosion when combined with split queries
- Avoids multiple passes over the data (no “load then map”)
Example DTO mapping:
public class BlogDto
{
public int Id { get; set; }
public string Title { get; set; }
public List<PostDto> Posts { get; set; }
}
public class PostDto
{
public int Id { get; set; }
public string Title { get; set; }
}
public class BlogProfile : Profile
{
public BlogProfile()
{
CreateMap<Blog, BlogDto>();
CreateMap<Post, PostDto>();
}
}Benchmarks — How Much Faster Is This?
I ran a simple benchmark with 10,000 Blogs, each having 5 Posts and 5 Comments, using SQL Server on a local machine.
| Approach | Rows Returned | SQL Queries | Time (ms) | Memory (MB) |
|---|---|---|---|---|
| Single Query + Include | ~250,000 | 1 | 850 ms | 220 MB |
| Split Query + Include | ~20,000 | 3 | 510 ms | 85 MB |
| AsNoTracking + Split Query | ~20,000 | 3 | 430 ms | 65 MB |
| AutoMapper ProjectTo (Split Query) | ~20,000 | 3 | 320 ms | 42 MB |
The difference is especially dramatic in memory usage — projecting with AutoMapper’s ProjectTo can cut memory by more than half compared to naive includes. This is a basic example. In real world scenarios, its common to have several include statements, which would push the number of rows and memory up exponentially.
Conclusion
EF Core’s defaults prioritize simplicity, but for large read-heavy workloads, you need to be intentional:
- Use split queries to avoid Cartesian explosion
- Use AsNoTracking for read-only queries
- Use projection (preferably with
ProjectTo) to reduce payloads and skip unnecessary entity tracking
When combined, these techniques can deliver massive performance wins — both in speed and memory usage.