Entity Framework Core makes it incredibly easy to work with databases in .NET applications.
You can write C# instead of SQL, work with strongly typed entities, use LINQ for queries, and let EF Core handle much of the database interaction for you.
But there's an important catch:
Writing code that works is not the same as writing code that performs well.
A LINQ query can look perfectly clean in C# while generating an inefficient SQL query behind the scenes. As your database grows from thousands to millions of records, small mistakes can turn into serious performance problems.
In this article, we'll look at 10 practical Entity Framework Core performance techniques that can make a real difference in ASP.NET Core applications.
We'll cover:
- How to avoid unnecessary columns
- Why
AsNoTracking()matters - Pagination
- Avoiding N+1 queries
- Eager loading
- Projection with
Select() - Database indexes
- Avoiding unnecessary
ToList() - Query compilation and caching
- How to inspect the SQL generated by EF Core
Let's get started.
📖 Table of Contents
- Understand What EF Core Is Actually Doing
- Select Only the Columns You Need
- Use
AsNoTracking()for Read-Only Queries - Always Consider Pagination
- Avoid the N+1 Query Problem
- Be Careful With
Include() - Prefer Projection with
Select() - Use Database Indexes
- Don't Call
ToList()Too Early - Inspect the SQL Generated by EF Core
- Combine Techniques for Real Applications
- Common EF Core Performance Mistakes
- Practical Checklist
- EF Core Interview Questions
- Frequently Asked Questions
- Final Thoughts
1. Understand What EF Core Is Actually Doing
Before optimizing EF Core, you need to understand one fundamental concept.
When you write:
var employees = await context.Employees
.Where(e => e.Department == "IT")
.ToListAsync();
EF Core doesn't simply execute C# code against the database.
Instead, EF Core translates the LINQ expression into SQL.
Conceptually:
C# LINQ
↓
EF Core
↓
SQL Query
↓
Database
↓
Results
↓
EF Core
↓
C# Objects
Your C# code might look simple:
context.Employees
.Where(e => e.Department == "IT")
But the database ultimately needs something similar to:
SELECT *
FROM Employees
WHERE Department = 'IT';
This is why understanding the SQL generated by EF Core is so important.
A query that looks elegant in C# isn't automatically efficient.
2. Select Only the Columns You Need
One of the easiest ways to improve database performance is to avoid retrieving unnecessary columns.
Consider:
var employees = await context.Employees
.ToListAsync();
This may retrieve every column from the Employees table.
But suppose your API only needs:
- Id
- Name
- Department
There is no reason to retrieve:
- Salary
- Address
- DateOfBirth
- ProfileImage
- CreatedDate
- UpdatedDate
Instead, use projection.
var employees = await context.Employees
.Select(e => new
{
e.Id,
e.Name,
e.Department
})
.ToListAsync();
Now the database only needs to return the required fields.
This can significantly reduce:
- Network traffic
- Database I/O
- Memory usage
- Object materialization
- API response processing
Real-World Example
Imagine an employee table contains 40 columns and 500,000 records.
If your API only needs three columns, retrieving complete entities unnecessarily can become expensive.
Projection allows the database to do less work.
Better API Design
You can also project directly into a DTO:
var employees = await context.Employees
.Select(e => new EmployeeDto
{
Id = e.Id,
Name = e.Name,
Department = e.Department
})
.ToListAsync();
This is generally preferable for API responses because you're explicitly controlling the data exposed by your application.
3. Use AsNoTracking() for Read-Only Queries
EF Core normally keeps track of entities it retrieves.
This tracking is useful when you're going to modify those entities.
For example:
var employee = await context.Employees
.FirstAsync(e => e.Id == id);
employee.Salary = 100000;
await context.SaveChangesAsync();
EF Core needs to know that the entity changed.
But what if you're only reading data?
For example:
var employees = await context.Employees
.AsNoTracking()
.ToListAsync();
AsNoTracking() tells EF Core:
I only need these entities for reading. Don't track them for changes.
This can reduce tracking overhead for read-heavy workloads.
When Should You Use It?
Good candidates include:
- Search endpoints
- Reporting APIs
- Dashboard queries
- Product listings
- Read-only pages
- Lookup data
For example:
var products = await context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
When Should You Avoid It?
Don't blindly use AsNoTracking() when you intend to modify the entity and call SaveChanges().
For example:
var employee = await context.Employees
.AsNoTracking()
.FirstAsync(e => e.Id == id);
employee.Salary = 90000;
await context.SaveChangesAsync();
The entity isn't being tracked, so EF Core won't automatically detect that change in the usual way.
Use tracking when you actually need change tracking.
4. Always Consider Pagination
One of the most common database performance mistakes is returning every record.
Imagine an endpoint:
GET /api/products
Your database contains 2 million products.
This is a terrible idea:
var products = await context.Products
.ToListAsync();
You're asking the application to retrieve potentially millions of rows.
Instead, use pagination.
int page = 1;
int pageSize = 20;
var products = await context.Products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
For page 1:
Skip = 0
Take = 20
For page 2:
Skip = 20
Take = 20
For page 3:
Skip = 40
Take = 20
Why Pagination Matters
Pagination reduces:
- Database result size
- Network traffic
- Application memory usage
- Serialization time
- API response size
For large datasets, pagination should be considered part of API design rather than an afterthought.
5. Avoid the N+1 Query Problem
The N+1 query problem is one of the most important EF Core performance issues to understand.
Suppose you have:
Orders
↓
Customer
You retrieve 100 orders:
var orders = await context.Orders
.ToListAsync();
Then inside a loop, you retrieve the customer:
foreach (var order in orders)
{
var customer = await context.Customers
.FirstAsync(c => c.Id == order.CustomerId);
}
You might accidentally execute:
1 query for Orders
+
100 queries for Customers
=
101 database queries
That's the N+1 problem.
Better Approach
Use an appropriate query that retrieves the required information together.
For example:
var orders = await context.Orders
.Select(o => new
{
o.Id,
o.OrderDate,
CustomerName = o.Customer.Name
})
.ToListAsync();
Now EF Core can translate this relationship into SQL and retrieve the required data more efficiently.
The important lesson is:
Be careful when accessing related data inside loops.
6. Be Careful With Include()
Include() is useful when you need related entities.
For example:
var orders = await context.Orders
.Include(o => o.Customer)
.ToListAsync();
This tells EF Core to load the related customer.
That can be appropriate.
But don't automatically add Include() everywhere.
For example:
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.ShippingAddress)
.ToListAsync();
You may end up retrieving a very large amount of related data.
Instead, ask:
Do I actually need these related entities?
If your API only needs the customer name, projection may be better:
var orders = await context.Orders
.Select(o => new
{
o.Id,
o.OrderDate,
CustomerName = o.Customer.Name
})
.ToListAsync();
The goal isn't to avoid Include().
The goal is to use it intentionally.
7. Prefer Projection with Select()
Projection is one of the most useful techniques in EF Core.
Consider:
var users = await context.Users
.ToListAsync();
You're loading complete entities.
But perhaps your UI only needs:
Id
Name
Email
Instead:
var users = await context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email
})
.ToListAsync();
This approach has several benefits.
1. Less Data
Only required columns are retrieved.
2. Better API Contracts
The API explicitly defines what it returns.
3. Less Memory
The application doesn't need to materialize unnecessary entity properties.
4. Better Security
You're less likely to accidentally expose fields that shouldn't be returned.
For example, don't return an entire user entity if it contains:
PasswordHash
SecurityStamp
InternalNotes
ResetToken
Instead, explicitly select what the API needs.
8. Use Database Indexes
EF Core query optimization isn't only about C#.
Your database design matters enormously.
Suppose you frequently execute:
var employees = await context.Employees
.Where(e => e.Email == email)
.FirstOrDefaultAsync();
If Email isn't indexed, the database may need to scan a large number of rows.
A database index can make lookups much faster.
For example, using EF Core configuration:
modelBuilder.Entity<Employee>()
.HasIndex(e => e.Email)
.IsUnique();
This creates an index on the email column.
Composite Indexes
Sometimes queries filter on multiple columns.
For example:
var orders = await context.Orders
.Where(o =>
o.CustomerId == customerId &&
o.Status == "Completed")
.ToListAsync();
A composite index may be useful:
modelBuilder.Entity<Order>()
.HasIndex(o => new
{
o.CustomerId,
o.Status
});
But don't create indexes for every column.
Indexes have costs too.
They can:
- Consume storage
- Increase insert/update overhead
- Increase maintenance cost
Indexes should be based on actual query patterns.
9. Don't Call ToList() Too Early
This is another common LINQ and EF Core mistake.
Consider:
var employees = await context.Employees
.ToListAsync();
var result = employees
.Where(e => e.Department == "IT")
.ToList();
The first ToListAsync() executes the database query immediately.
That means the application retrieves all employees before filtering them.
A better approach is:
var result = await context.Employees
.Where(e => e.Department == "IT")
.ToListAsync();
Now the filtering happens at the database level.
Conceptually:
Less Efficient
Database
↓
All Employees
↓
Application
↓
Filter
Better
Database
↓
Filter
↓
Only IT Employees
↓
Application
This is one of the most important concepts when working with IQueryable.
10. Inspect the SQL Generated by EF Core
Never assume your LINQ query produces the SQL you expect.
EF Core provides ways to inspect generated SQL.
For example:
var query = context.Employees
.Where(e => e.Department == "IT");
Console.WriteLine(query.ToQueryString());
This can show the SQL generated for the query.
This is incredibly useful when diagnosing:
- Slow queries
- Unexpected joins
- Missing filters
- Excessive columns
- Unexpected SQL generation
Example
You may write:
var result = context.Employees
.Where(e => e.Salary > 80000)
.OrderBy(e => e.Name);
Instead of guessing what happens, inspect the generated SQL.
Then you can take the SQL to your database tools and analyze the execution plan.
Remember
EF Core is an abstraction over the database.
When performance matters, you should understand both:
C# / LINQ
and:
SQL / Database Execution
Being comfortable with both makes you a much stronger .NET developer.
11. Combine Techniques for Real Applications
The biggest performance improvements usually come from combining multiple techniques.
Suppose you're building an employee search API.
A good query might look like:
var employees = await context.Employees
.AsNoTracking()
.Where(e => e.IsActive)
.Where(e => e.Department == department)
.OrderBy(e => e.Name)
.Select(e => new EmployeeDto
{
Id = e.Id,
Name = e.Name,
Department = e.Department
})
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
This single query demonstrates several useful techniques:
-
AsNoTracking()for read-only data - Filtering at the database
- Ordering
- Projection
- Pagination
- Async database access
That's much better than retrieving thousands of complete entities and processing them inside application code.
12. Common EF Core Performance Mistakes
Here are some mistakes worth checking in your own projects.
❌ Loading Everything
context.Products.ToListAsync();
when the application only needs 20 records.
❌ Selecting Complete Entities
context.Users.ToListAsync();
when the API only needs three properties.
❌ Filtering After ToList()
context.Users
.ToList()
.Where(...);
❌ Unnecessary Tracking
Using tracked entities for large read-only reports.
❌ N+1 Queries
Executing database queries inside loops.
❌ Too Many Includes
Loading a huge object graph when only a few fields are required.
❌ Ignoring Database Indexes
Filtering and sorting frequently on columns that aren't appropriately indexed.
❌ Ignoring Generated SQL
Assuming that clean LINQ automatically means efficient SQL.
13. Practical EF Core Performance Checklist
Before releasing an API that uses EF Core, ask yourself:
☐ Am I selecting only the columns I need?
☐ Is this query read-only?
☐ Should I use AsNoTracking()?
☐ Does this endpoint need pagination?
☐ Am I accidentally creating N+1 queries?
☐ Do I really need every Include()?
☐ Can I use projection with Select()?
☐ Are frequently filtered columns indexed?
☐ Am I calling ToList() too early?
☐ Have I inspected the generated SQL?
☐ Have I tested the query with realistic data volumes?
☐ Have I checked the database execution plan?
The last two are especially important.
A query that performs well with 1,000 records may behave very differently with 10 million records.
Always test performance using data volumes that resemble production.
14. EF Core Interview Questions
What is AsNoTracking()?
AsNoTracking() tells EF Core not to track returned entities for changes. It is commonly useful for read-only queries.
Why is Select() useful for performance?
It allows you to retrieve only the fields required by the application instead of loading complete entities.
What is the N+1 query problem?
It occurs when an application executes one query to retrieve a collection and then executes additional queries for each item in that collection.
Why is ToList() important?
ToList() materializes the query and generally causes the database query to execute immediately.
Calling it too early can cause unnecessary data to be retrieved.
Does EF Core automatically create indexes?
EF Core creates indexes for certain conventions, such as primary keys, but application-specific indexes often need to be configured intentionally.
What is projection?
Projection means selecting only the data required from an entity, often using Select().
Why should generated SQL be inspected?
Because the LINQ expression you write doesn't always make the resulting SQL obvious. Inspecting SQL helps identify inefficient queries, joins, missing filters, and other database performance issues.
15. Frequently Asked Questions
Is EF Core slower than writing SQL manually?
Not necessarily.
EF Core can generate efficient SQL for many common scenarios, but developers still need to understand how their queries translate to SQL.
For highly specialized queries, raw SQL or other database-specific approaches can sometimes be appropriate.
The important thing is to measure rather than assume.
Should I always use AsNoTracking()?
No.
Use it when you don't need change tracking.
If you're retrieving an entity specifically to modify and save it, normal tracking may be appropriate.
Should I avoid Include()?
No.
Include() is useful when you genuinely need related entities.
The problem is using it without considering how much data you're loading.
Is Select() always faster?
Not automatically.
Projection can reduce the amount of data retrieved, but the actual performance depends on the query, database, indexes, relationships, and data size.
Measure important queries.
How can I find slow EF Core queries?
Start by logging SQL generated by EF Core and inspecting the queries using your database's execution-plan tools.
Application performance monitoring and database monitoring can also help identify slow queries in production.
16. Final Thoughts
Entity Framework Core allows .NET developers to work with databases using familiar C# and LINQ.
But abstraction doesn't eliminate the need to understand what's happening underneath.
The most important performance lessons from this article are:
- Retrieve only the data you need.
- Use
AsNoTracking()for appropriate read-only queries. - Paginate large datasets.
- Watch out for N+1 queries.
- Use
Include()intentionally. - Prefer projection when you only need specific fields.
- Design appropriate database indexes.
- Don't materialize queries too early.
- Inspect the SQL generated by EF Core.
- Test with realistic production-sized data.
The biggest mistake is optimizing based on assumptions.
Instead of thinking:
"This LINQ query looks simple, so it must be fast."
Think:
"What SQL will this generate, how will the database execute it, and how will it behave when the data grows?"
That mindset will help you write significantly better ASP.NET Core applications.
🚀 Explore More Free Developer Tools
As developers, we spend a surprising amount of time working with API responses, configuration files, tokens, test data, and different data formats while building applications.
That's one of the reasons I built ToolBenchApp.
ToolBenchApp is a growing collection of free, browser-based developer tools designed to simplify everyday development tasks.
For .NET and API developers, some particularly useful tools include:
- JSON Formatter & Validator
- JSON ↔ XML Converter
- JSON ↔ CSV Converter
- JWT Decoder
- Base64 Encoder/Decoder
- UUID Generator
- URL Encoder/Decoder
- SQL Formatter
- YAML Formatter
- Text Difference Checker
- HTML Formatter
- Regex Tester
For example, when debugging an ASP.NET Core API, you can quickly format a JSON response, inspect a JWT, compare two API payloads, or format SQL without installing another application.
I'm continuously adding new utilities to ToolBenchApp based on practical developer needs. If there's a small developer task you repeatedly perform and would like to turn into a free browser-based tool, I'd love to hear your suggestion.
Happy coding! 🚀
Top comments (0)