You already know IMemoryCache and IDistributedCache. This lesson is about something EF Core gives you for free, one layer below either of them — and about the judgment call of where caching belongs at all.
Run this and think about what should happen on the second line:
Product first = await context.Products.FindAsync(productId);
Product second = await context.Products.FindAsync(productId);
Console.WriteLine(ReferenceEquals(first, second)); // trueNo round trip happened for second. No SQL was sent. And it's not just that the values match — first and second are literally the same object in memory. You didn't configure a cache anywhere. You didn't call IMemoryCache. EF Core did this on its own, and it's been doing it the whole time you've been using DbContext — you just haven't had a reason to look closely at it until now.
Advanced Part VII's 265 and 266 lessons taught you application-level caching — IMemoryCache for one process, IDistributedCache for many. This lesson doesn't re-teach either one. It's about the caching layer that sits underneath both of them, specific to data access: what EF Core's change tracker is already doing for you inside a single DbContext, and the honest judgment call of which layer — EF Core's own, IMemoryCache, or IDistributedCache — a given piece of cached data actually belongs at.
In this lesson, you'll learn what EF Core's first-level cache actually is and how it's genuinely different from application-level caching, when to cache at the data-access layer versus a layer above it, how to think about invalidating cached data specifically (tying back to 266's cache-aside pattern), and how read-heavy versus write-heavy workloads should change what you decide is worth caching at all.
EF Core's first-level cache is the change tracker's own bookkeeping — the same mechanism that tracks which entities are new, modified, or unchanged for SaveChangesAsync() also happens to remember, by primary key, every entity it has already loaded during the lifetime of one DbContext instance. Ask for the same row by the same key twice, and the second ask is answered from that memory, not from the database.
This is formally called an identity map: within one DbContext instance, the change tracker guarantees that any two queries resolving to the same entity type and the same primary key value return the exact same tracked object instance, not two separate objects with equal data. When a tracking query's results include a row whose key is already tracked, EF Core discards the freshly-read row's values (by default) and returns the already-tracked instance instead — the query still reaches the database, but the entity you get back is the one already in memory. FindAsync goes further: it checks the tracked set before issuing any query at all, and skips the database round trip entirely on a hit.
Imagine an Order that references a Customer, and your code separately loads that same Customer directly earlier in the same unit of work. Without an identity map, the customer referenced by the order and the customer you loaded directly would be two entirely separate C# objects — and if you changed a property on one, the other wouldn't reflect it, even though they represent the exact same database row. SaveChangesAsync() would then have two different sets of "what changed" for what's supposed to be a single row, which is exactly the kind of inconsistency that leads to silently lost updates or unpredictable behavior.
The identity map guarantees there's only ever one tracked instance per entity key, no matter how many different query paths lead back to that same row within the same DbContext. That guarantee is what makes change tracking coherent at all — and skipping a redundant round trip when a key is already resolved is a natural, useful side effect of enforcing it, not the primary reason the mechanism exists.
| Layer | Scope | Survives across... | Covered in |
|---|---|---|---|
| EF Core's identity map (first-level cache) | One DbContext instance | Nothing — a DbContext is typically scoped to a single request or unit of work; it's gone the moment that instance is disposed | This lesson |
| IMemoryCache | One running application process | Many requests, many DbContext instances — but only within that one process | 265-caching |
| IDistributedCache | Every instance of the application sharing the same backing store | Requests, processes, and instances — the widest scope of the three | 266-distributed-caching |
This is the core distinction the whole lesson turns on: the identity map is not a smaller version of IMemoryCache — it's a fundamentally narrower-scoped, different-purpose mechanism, and it isn't something you configure or reach for on purpose the way you do with 265 and 266's caches. It's simply always there, working underneath ordinary tracking queries.
await using AppDbContext context = new(options);
Product a = await context.Products.FindAsync(1);
a.Name = "Renamed In Memory";
Product b = await context.Products.FindAsync(1); // no query — comes from the identity map
Console.WriteLine(b.Name); // "Renamed In Memory"
Console.WriteLine(ReferenceEquals(a, b)); // trueb reflects the in-memory change made to a — not because EF Core re-read the database, but because a and b are literally the same object. This is the identity map doing exactly its job: one row, one object, for the life of this DbContext.
Consider a product detail page that: (1) loads the product itself, (2) separately loads its reviews, which each reference the same product by foreign key, and (3) checks the product's current price against a slow, expensive tax-calculation service.
| Data | Right layer | Why |
|---|---|---|
| The Product entity, loaded once and referenced by its reviews within the same request | EF Core's identity map (automatic — no code needed) | Scoped correctly to one request's DbContext; you get this for free just by using tracking queries consistently |
| The product catalog's category list, read by every user, changes rarely, single-instance app | IMemoryCache | Needs to survive across many requests within the process — the identity map can't help here, since it dies with the request's DbContext |
| The tax-calculation result, needed by every instance of a multi-server deployment, expensive to recompute | IDistributedCache | Must be consistent and shared across every running instance, not just one process — exactly 266's motivating case |
Three different pieces of data on the same page, three different correct caching layers — and none of the three decisions overlaps with either of the other two.
EF Core's identity map is like the sticky notes on your desk while you're working on one specific task — the moment you already have a document out, you don't get up and re-fetch it; it's right there. The moment the task ends (the DbContext is disposed), the desk is cleared.
IMemoryCache is the filing cabinet in your own office — it survives between tasks, as long as you're the one working in that office (one process). Someone in a different office (a different server instance) can't see into your cabinet at all.
IDistributedCache is the building's shared archive room — anyone in any office can check something out or file something in, and everyone sees the same, current contents.
Caching's genuinely hard half was never storing the value — it's knowing when the stored value has gone stale. 265 and 266 already introduced this at the application-cache level; here's how it plays out specifically around data access.
Time-based expiration is simpler to write and gets you most of the way for slow-changing, low-stakes data. Event-based invalidation is more precise — no window of staleness at all — but only as reliable as your discipline in remembering to invalidate on every single write path that can change the cached value. Many real systems combine both: an event-based invalidation as the primary mechanism, with a time-based expiration as a safety net in case an invalidation path gets missed.
No — they solve different problems at different lifetimes. The identity map only ever helps within a single DbContext instance, which usually means a single request. It does nothing for the next request, or the next user, hitting the exact same query. If a query result genuinely needs to survive and be reused across requests, that's squarely IMemoryCache's (or IDistributedCache's) job, not the identity map's.
They don't. The identity map is a function of the change tracker — a no-tracking query, by definition, opts out of change tracking entirely, so there's no tracked-entity registry for it to check against or register into. Two identical AsNoTracking() queries for the same key, in the same DbContext, will genuinely hit the database twice and return two separate object instances with equal (but distinct) data. That's an expected, correct trade-off of no-tracking queries, from Intermediate's 146 lesson — not a bug.
Reaching straight for IDistributedCache for everything, "to be safe," including data that's only ever needed within a single request and would have been handled for free by the identity map. Match the cache to the actual required lifetime — request-scoped reuse needs nothing extra at all if you're already using tracking queries consistently; only reach for an explicit cache once data genuinely needs to outlive one DbContext.
Applying the same generous cache TTL to a frequently-updated inventory count as to a rarely-changing product description — the inventory count now regularly serves stale values right when accuracy matters most (avoiding overselling). Let the read-to-write ratio drive the decision: cache aggressively where reads vastly outnumber writes and staleness is cheap; cache cautiously, briefly, or not at all where writes are frequent and staleness has a real cost.
Relying on invalidation logic wired into your normal SaveChangesAsync() save path, then running an ExecuteUpdateAsync bulk operation that changes the same rows without going anywhere near that path — any cache entries for the affected data go stale with nothing to catch it. Any code path that changes data — including bulk operations — needs its own explicit invalidation step if cached values depend on that data.
You've seen EF Core's own built-in caching mechanism and how to decide where cached data genuinely belongs. Let's confirm it clicked.
1. Within a single DbContext, code calls await context.Products.FindAsync(5) twice in a row. What happens on the second call?
Correct: B
Why B is correct: FindAsync checks the change tracker's identity map before issuing any query — on a hit, it returns the already-tracked instance directly, with no database round trip at all.
Why A is incorrect: No query is sent on the second call in the first place — there's no query plan involved because nothing is executed.
Why C is incorrect: Finding an already-tracked entity again is completely normal and expected — it's the identity map working correctly, not an error condition.
Why D is incorrect: This describes what would happen without an identity map — the whole point of the mechanism is to prevent exactly this.
Reinforcement: FindAsync specifically checks tracked state before querying — that's what makes it skip the round trip entirely, not just deduplicate after the fact.
2. What is the key difference between EF Core's identity map and IMemoryCache?
Correct: B
Why B is correct: This is the core scope distinction the lesson builds around — the identity map lives and dies with one DbContext (typically one request), while IMemoryCache persists across many requests within the same process.
Why A is incorrect: They operate at genuinely different lifetimes and serve different purposes — conflating them is exactly the confusion this lesson exists to clear up.
Why C is incorrect: This is backwards — the identity map is the one that's automatic and unconfigured; IMemoryCache requires you to deliberately register it and write caching code around it.
Why D is incorrect: Also backwards, and neither is instance-wide — the identity map is the narrowest scope of the three layers discussed (one DbContext), not the widest.
Reinforcement: Scope and lifetime, not mechanism similarity, is what actually separates these two layers.
3. Two identical AsNoTracking() queries, for the same entity key, run back to back within the same DbContext. What happens?
Correct: B
Why B is correct: No-tracking queries opt out of change tracking entirely, which means there's no identity map registry for them to check or register into — each query independently hits the database and returns its own separate object.
Why A is incorrect: The identity map only applies to tracked entities — AsNoTracking() specifically disables that mechanism, by design, as Intermediate's 146 lesson covered.
Why C is incorrect: There's nothing wrong with running the same no-tracking query twice — it's a completely normal, if slightly redundant, pattern.
Why D is incorrect: EF Core never silently upgrades a no-tracking query to a tracking one — tracking behavior is exactly what you asked for and exactly what you get.
Reinforcement: The identity map is a change-tracking feature — opting out of tracking opts out of the identity map along with it.
4. A live inventory count changes dozens of times per hour and overselling stock is a real business cost if a customer sees a stale "in stock" value. Which approach best fits this lesson's guidance?
Correct: B
Why B is correct: This is exactly the write-heavy, high-staleness-cost profile the lesson flags as a poor caching candidate — frequent writes mean a cached value goes stale fast, and the real business cost of overselling makes that staleness expensive, not just inconvenient.
Why A is incorrect: The lesson explicitly rejects "caching always helps" as a blanket rule — for write-heavy, high-stakes data, aggressive caching increases risk rather than reducing it.
Why C is incorrect: The identity map is scoped to a single DbContext/request — it does nothing for data that needs to be consistent and current across many requests and users.
Why D is incorrect: IDistributedCache is not immune to staleness — nothing about it, or the lack of an expiration, makes a cached value automatically accurate after the underlying data changes.
Reinforcement: Read/write ratio and the real cost of staleness — not "can I cache this," but "should I" — is the judgment call this lesson is built around.
You now understand EF Core's own built-in caching layer, how it differs from the application-level caches you already know, and how to reason about where cached data genuinely belongs. Next up: an honest, balanced look at when raw SQL actually beats EF Core — and when it doesn't.
dotnetmadeeasy.com — Learn C# and .NET, the right way.