5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
5 EF Core Features You Need to Know
Imagine you run a small grocery shop. You keep a notebook where you write every item, its price, and how much stock is left. When a customer buys something, you cross it out and update the count. That notebook is your database. Writing in it by hand, line by line, is slow and easy to get wrong.
Now imagine a smart helper who reads your notebook for you, remembers exactly what you changed, and writes the updates back neatly when you say "save". That helper is Entity Framework Core, or EF Core for short.
EF Core is an ORM (Object-Relational Mapper) for .NET. It lets you talk to a database using normal C# classes instead of writing raw SQL all day. In this post we will look at five EF Core features that every beginner should understand. We will keep the language simple and use small, clear examples. By the end you will know what to use, when to use it, and why it matters.
Here is the big picture before we start.
Let me introduce the five features we will cover, then go through each one slowly.
| # | Feature | What it does in one line |
|---|---|---|
| 1 | Change Tracking | Remembers what you changed so SaveChanges knows what to write |
| 2 | AsNoTracking | Skips that memory work for fast read-only screens |
| 3 | Eager Loading (Include) | Loads related data together in fewer trips |
| 4 | Bulk ExecuteUpdate | Updates many rows directly in the database |
| 5 | Query Filters | Adds an automatic rule (like "hide deleted rows") to every query |
We are using .NET 10, which is the current Long Term Support (LTS) release, and the EF Core version that ships with it. The ideas here work in older versions too, but a few details (like the newer ExecuteUpdate overloads) are nicest in EF Core 10.
Feature 1: Change Tracking
The idea
Think back to the shop notebook. When the smart helper reads a page for you, it quietly takes a photo of how that page looked. Later, when you change something, the helper compares the new page to the photo. It can tell exactly what is new, what changed, and what was removed. This "photo and compare" job is called change tracking.
By default, when EF Core loads an entity from the database, it starts tracking it. From that moment, EF Core watches the object. When you call SaveChanges(), EF Core looks at what changed and writes only those changes back as SQL.
A simple example
using var context = new ShopContext();
// EF Core loads this product AND starts tracking it
var product = context.Products.First(p => p.Name == "Rice");
// We change one property
product.Price = 55;
// EF Core noticed the change and writes an UPDATE for just the Price column
context.SaveChanges();Notice that we never wrote any SQL. We did not say "UPDATE Products SET Price...". EF Core figured that out because it was tracking the product object and saw that only Price changed.
What change tracking is doing under the hood
Each tracked entity has a state. The state tells EF Core what to do at save time.
| State | Meaning | What SaveChanges does |
|---|---|---|
| Unchanged | Loaded but not edited | Nothing |
| Added | A brand new object | INSERT |
| Modified | An edited object | UPDATE |
| Deleted | Marked for removal | DELETE |
| Detached | EF Core is not watching it | Nothing |
Here is the life cycle as a state diagram.
Why this matters
Change tracking is what makes EF Core feel magical. You edit plain C# objects, call one method, and the right SQL happens. For most normal app screens (a form where a user edits and saves), this is exactly what you want.
But there is a cost. To track entities, EF Core has to store those "before" snapshots and check them. If you load 10,000 rows just to show them on a page and never edit them, all that tracking work is wasted. That leads us straight to the next feature.
Feature 2: AsNoTracking for Fast Reads
The idea
Sometimes you only want to look, not change. Think of reading a price list taped to the shop wall. You read it and walk away. You do not need the helper to take a photo and watch for edits, because you are not editing anything.
AsNoTracking() tells EF Core: "I am only reading. Do not bother tracking these objects." Because EF Core skips the snapshot and comparison work, the query runs faster and uses less memory. Microsoft's docs and many community benchmarks show read speed-ups in the range of roughly 20% to 40% for read-only queries, with lower memory pressure.
A simple example
using var context = new ShopContext();
// Read-only list for a "browse products" page
var products = context.Products
.AsNoTracking()
.Where(p => p.IsAvailable)
.OrderBy(p => p.Name)
.ToList();
// We only display these. We do not edit and save them.
foreach (var p in products)
{
Console.WriteLine($"{p.Name}: {p.Price}");
}If you later try to edit one of these objects and call SaveChanges(), nothing will be saved, because EF Core is not tracking them. That is the point. Use AsNoTracking() only when you will not save changes.
Tracking vs no-tracking at a glance
Choosing tracking vs no-tracking
Steps
Need to save?
Will you edit and call SaveChanges?
Tracking
Yes - keep default tracking
AsNoTracking
No - read-only, use AsNoTracking
A tip for read-heavy apps
If most of your app is read-only (like a public catalog), you can make no-tracking the default for the whole context, then opt back in to tracking only where you edit:
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}After this, your queries are no-tracking unless you call .AsTracking() on the ones where you actually edit data. This is a small change that can give a nice, broad performance win.
Feature 3: Eager Loading with Include
The idea
In a shop, a product belongs to a category, and a category has many products. In C# you might have a Product class with a Category property. When you load products, the Category data is not loaded automatically by default. If you forget to ask for it, you can run into a slow pattern where EF Core makes one extra trip to the database for every single row. This is the famous N+1 problem.
The polite, fast way is to say up front: "While you are at it, also bring the category." That is eager loading, and you do it with Include.
using var context = new ShopContext();
var products = context.Products
.Include(p => p.Category) // bring the related Category too
.AsNoTracking()
.ToList();
foreach (var p in products)
{
// p.Category is already loaded - no extra database trip here
Console.WriteLine($"{p.Name} is in {p.Category.Name}");
}You can go deeper with ThenInclude when there are chains of relations, for example product to category to supplier.
Why N+1 is a trap
Look at the difference between forgetting Include and using it.
If you load 100 products without Include and then touch each Category, you can end up with 101 database trips. With Include, it is often just one query that joins the data. Fewer trips means a faster app.
A note on big joins
When you Include many related collections at once, the single joined query can return a lot of repeated data and grow large. EF Core gives you AsSplitQuery() to split that into several smaller queries instead of one giant join. It is a handy tool, and there is a separate article on query splitting if you want to go deeper. For everyday cases with one or two includes, the normal single query is fine.
Feature 4: Bulk Updates with ExecuteUpdate
The idea
Suppose the shop owner says: "Increase the price of every drink by 10%." With change tracking, you would load every drink into memory, change each one, and save. If there are 50,000 drinks, that means loading 50,000 objects, tracking them all, and sending many updates. That is heavy.
ExecuteUpdate (and its partner ExecuteDelete) let you update or delete rows directly in the database with one set-based SQL command. EF Core does not load the rows into memory at all. This was added in EF Core 7 and keeps getting better.
using var context = new ShopContext();
// Raise every drink price by 10%, all in the database, no entities loaded
await context.Products
.Where(p => p.Category.Name == "Drinks")
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.10m));That single call becomes roughly one UPDATE ... WHERE ... SQL statement. No loading, no per-row tracking, no big memory use. For large updates this is dramatically faster.
What is new in EF Core 10
In EF Core 10, ExecuteUpdate got friendlier. It now accepts a regular lambda (a normal Func) for the setters, not only a strict expression tree. This means you can build the update logic with local variables, loops, and helper methods more easily, which is great for dynamic updates. EF Core 10 also lets you target JSON columns inside ExecuteUpdateAsync, so you can bulk-update document-style data stored in relational tables.
The one big warning
ExecuteUpdate and ExecuteDelete bypass the change tracker. EF Core does not know about these changes in memory, and saving interceptors do not fire for them. So if you have already loaded some of those rows, your in-memory copies will be stale.
Loaded entities vs ExecuteUpdate
Steps
Bulk update DB
ExecuteUpdate changes rows in the database
Memory not updated
Tracked entities in memory are now out of date
Reload if needed
Query again to get fresh values
Rule of thumb: use ExecuteUpdate for big "change many rows" jobs where you do not need the entities in memory. Use normal change tracking for editing one or a few objects on a screen.
Quick comparison
| Approach | Loads rows into memory? | Good for |
|---|---|---|
| Load + edit + SaveChanges | Yes | Editing one or a few records |
| ExecuteUpdate / ExecuteDelete | No | Updating or deleting many rows fast |
Feature 5: Query Filters (Global and Named)
The idea
In many apps you "delete" a row by just marking it as deleted instead of really removing it. This is called soft delete. The problem is that now every query must remember to add Where(x => !x.IsDeleted). Forget it once, and deleted items leak onto the screen.
Query filters solve this. You set a rule once on the model, and EF Core adds it automatically to every query for that entity. It is like telling your helper: "From now on, never show me crossed-out items, unless I clearly ask for them."
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// This filter is applied automatically to every Product query
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
}Now this query never returns deleted products, even though you did not write the filter:
// EF Core silently adds "WHERE IsDeleted = 0" for you
var products = context.Products.ToList();If you ever need the deleted ones (for an admin report, say), you opt out for that one query with IgnoreQueryFilters().
Named query filters in EF Core 10
Older EF Core allowed only one filter per entity type. If you wanted both "hide deleted" and "only this tenant's data" in a multi-tenant app, you had to mash them into one expression and turn them off together.
EF Core 10 adds named query filters. You can define multiple filters on the same entity, each with a name, and turn them off one at a time. That is much cleaner.
modelBuilder.Entity<Product>()
.HasQueryFilter("SoftDelete", p => !p.IsDeleted)
.HasQueryFilter("Tenant", p => p.TenantId == currentTenantId);
// Later, disable only the soft-delete filter, keep the tenant filter
var all = context.Products
.IgnoreQueryFilters(new[] { "SoftDelete" })
.ToList();How a query filter flows through a request
Query filters are perfect for soft delete and multi-tenant apps. There is a dedicated article on implementing soft delete with EF Core if you want a full walk-through.
Putting It All Together
These five features are not separate islands. They work together in a real screen. Imagine a "product catalog" page that also lets an admin bump prices.
A real request using several features
Steps
Read list
AsNoTracking + Include for fast display
Edit one
Tracked entity + SaveChanges to edit a product
Bulk update
ExecuteUpdate to raise many prices at once
Filtered always
Query filter hides deleted items everywhere
A good mental model is this: track only when you save, read with no-tracking, load related data on purpose, update many rows in the database, and set common rules once with filters. When you follow these habits, your EF Core code stays both fast and simple.
Here is a tiny combined example to make it concrete.
using var context = new ShopContext();
// 1 + 2 + 3 + 5: a fast, filtered, read-only list with related data
var catalog = context.Products
.AsNoTracking() // Feature 2
.Include(p => p.Category) // Feature 3
.ToList(); // Feature 5 filter applied automatically
// 1: tracked edit of a single product
var rice = context.Products.First(p => p.Name == "Rice");
rice.Price = 60; // Feature 1 change tracking
context.SaveChanges();
// 4: bulk price rise for all drinks, no entities loaded
await context.Products
.Where(p => p.Category.Name == "Drinks")
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.10m));Common Beginner Mistakes to Avoid
- Using the default tracking for huge read-only lists. Add
AsNoTracking()and your pages get lighter. - Forgetting
Includeand falling into the N+1 problem. If a page feels slow and your logs show many tiny queries, this is usually why. - Using
ExecuteUpdateand then trusting stale in-memory objects. Reload after a bulk update if you still need the values. - Writing the same
Where(x => !x.IsDeleted)everywhere instead of one query filter. - Calling
SaveChanges()inside a loop for thousands of rows. Prefer a singleExecuteUpdateor a batched save.
References and Further Reading
- What's New in EF Core 10 (Microsoft Learn)
- Tracking vs. No-Tracking Queries (Microsoft Learn)
- Overview of Entity Framework Core (Microsoft Learn)
- What You Need To Know About EF Core Bulk Updates (Milan Jovanovic)
- EF Core ExecuteUpdate (Learn Entity Framework Core)
- EF Core 10 - Top New Features (Learn Entity Framework Core)
Quick Recap
- EF Core lets you use a database with normal C# classes instead of hand-writing SQL.
- Change tracking (Feature 1) remembers what you edited so
SaveChanges()writes the right SQL. Great for editing screens. - AsNoTracking (Feature 2) skips that bookkeeping for read-only lists, giving faster and lighter queries.
- Eager loading with Include (Feature 3) brings related data in fewer trips and avoids the N+1 problem.
- ExecuteUpdate (Feature 4) changes many rows directly in the database without loading them. It bypasses the change tracker, so handle stale data carefully. EF Core 10 adds friendlier lambdas and JSON column support.
- Query filters (Feature 5) set a rule once (like "hide deleted") and apply it everywhere. EF Core 10 adds named filters you can toggle one at a time.
- Combine them wisely: track only when saving, read with no-tracking, include on purpose, bulk-update in the database, and filter globally.
Related Posts
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
The Correct Way to Use Batch Update and Batch Delete in EF Core
Learn the correct, safe way to use ExecuteUpdate and ExecuteDelete batch methods in EF Core, with transactions, change tracker tips, and EF Core 10 features.
Optimizing Bulk Database Updates in .NET: A Practical Guide
Learn how to make bulk database updates fast in .NET using EF Core ExecuteUpdate, batching, and transactions, with simple examples and EF Core 10 tips.