Nano ASP.NET SaaS Boilerplate
Admin credentials (all tenants): admin@email.com / Password123!
Sample data resets every hour

Entity Framework C# Optimization Techniques

Ryan Lewis
August 12, 2025

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

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.BlogId

If 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.

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));

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 ChangeTracker work

You can combine AsNoTracking() with split queries for maximum read performance.

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 memory

You 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>();
    }
}

I ran a simple benchmark with 10,000 Blogs, each having 5 Posts and 5 Comments, using SQL Server on a local machine.

ApproachRows ReturnedSQL QueriesTime (ms)Memory (MB)
Single Query + Include~250,0001850 ms220 MB
Split Query + Include~20,0003510 ms85 MB
AsNoTracking + Split Query~20,0003430 ms65 MB
AutoMapper ProjectTo (Split Query)~20,0003320 ms42 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.

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Need an ASP.NET Boilerplate to build your next MVP?
Check out Nano ASP.NET multi-tenant SaaS boilerplate project and save weeks or months of development time.
Learn More