EF Core (Entity Framework Core) has no built-in second-level cache. The change tracker is a first-level cache scoped to a single DbContext, so the moment you want to reuse query results across requests, you add a library. The one everyone uses is EFCoreSecondLevelCacheInterceptor, which plugs in as an EF Core interceptor, caches query results keyed by the generated SQL, and invalidates them on SaveChanges.
That gets you a repeated read served from memory instead of the database. The part nobody benchmarks or warns you about is where it goes wrong: ExecuteUpdate, raw SQL, and writes from other services all slip past the auto-invalidation and serve stale data. I set this up on .NET 10 with EF Core 10, benchmarked the cache hit against a real database round trip, and mapped exactly when this is worth it and when it will burn you. Let’s get into it.
TL;DR. EF Core 10 has no native second-level (result) cache; its first-level cache is the change tracker, per
DbContext. Add one withEFCoreSecondLevelCacheInterceptor(v5.5.0). In v5 the cache providers are separate NuGet packages - installEFCoreSecondLevelCacheInterceptor.MemoryCache(or.StackExchange.Redis/.HybridCache), callAddEFSecondLevelCache(...), and add theSecondLevelCacheInterceptorto yourDbContext. Cache per-query with.Cacheable(...)or globally withCacheAllQueries(...). It auto-invalidates onSaveChanges, but not onExecuteUpdate/ExecuteDelete, raw SQL, or writes from another service - clear those yourself viaIEFCacheServiceProvider. Cache read-heavy reference data; never cache volatile or per-user data.
You need .NET 10, EF Core 10, and Docker to follow along. The complete, runnable sample - a BenchmarkDotNet project plus a Web API showing the setup, cached reads, and the manual-invalidation fix - is in the GitHub repo. Clone it, run docker compose up in that folder to get PostgreSQL and Redis, and reproduce every number below.
EF Core Interceptors: The Complete Guide
A second-level cache is built on an interceptor. This explains how interceptors hook the EF Core pipeline.
What Is Second-Level Caching in EF Core?
Second-level caching stores the results of a query in a cache shared across DbContext instances and requests, so a repeated query returns from memory (or Redis) instead of running against the database again. It sits below your queries and above the database, transparently short-circuiting the round trip when the same query runs twice.
The name comes from the contrast with the first-level cache. Here is the distinction that trips people up:
| First-level cache | Second-level cache | |
|---|---|---|
| What it is | The change tracker / identity map | A shared query-result cache |
| Scope | One DbContext instance | Across contexts and requests |
| What it caches | Tracked entity instances (by key) | Query result sets (by SQL + parameters) |
| Built into EF Core? | Yes | No - needs a library |
| Skips the DB round trip? | Only Find/FindAsync | Yes, on a cache hit |
There is a myth worth killing here: the first-level cache does not mean running the same LINQ query twice skips the database. A tracking query like context.Products.Where(...).ToList() still executes SQL every time. EF Core’s change tracker only resolves identity afterward: if a returned row matches an entity it already tracks, you get the existing instance back. The one method that checks the cache before querying is DbContext.Find/FindAsync. So the first-level cache is an identity map, not a query-result cache. That is exactly the gap a second-level cache fills.
Tracking vs. No-Tracking Queries in EF Core 10
The first-level cache is really the change tracker. This covers how tracking and identity resolution actually work.
Does EF Core Have a Built-In Second-Level Cache?
No. EF Core 10 has no built-in second-level cache, and none was added in EF Core 10. EF Core does have an internal query cache, but that caches the LINQ-to-SQL translation, not the results - the database is still queried. To cache actual query results across requests, you need a third-party library.
Three separate things use the word “cache” here, and only one of them skips the database:
- Internal query/plan cache - automatic, caches the compiled SQL for a query shape. Database still queried.
- Compiled models (
dotnet ef dbcontext optimize) - startup-time model building for large models. Not a result cache. - Second-level cache - caches the query results themselves. This is the one you have to add.
How Do I Add Second-Level Caching in EF Core 10?
Three steps: install a cache provider package, register it, and add the interceptor to your DbContext. I use EFCoreSecondLevelCacheInterceptor, version 5.5.0 (current as of July 2026), which supports EF Core 10 on .NET 10.
The important change in v5, and the thing every older tutorial gets wrong: the cache providers were split into separate NuGet packages. The core package no longer ships an in-memory provider, so installing only EFCoreSecondLevelCacheInterceptor gives you no UseMemoryCacheProvider() method. Install the core package plus one provider:
dotnet add package EFCoreSecondLevelCacheInterceptordotnet add package EFCoreSecondLevelCacheInterceptor.MemoryCacheRegister the cache in Program.cs:
builder.Services.AddEFSecondLevelCache(options => options .UseMemoryCacheProvider() .ConfigureLogging(true) .UseCacheKeyPrefix("EF_") .UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1)));Then wire the interceptor into your DbContext through the service provider, so it resolves alongside the context:
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) => options .UseNpgsql(connectionString) .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()));That is the whole setup. The interceptor is a DbCommandInterceptor: it computes a cache key from the SQL command text plus its parameter values, and on a matching query it returns the cached result set instead of executing the command.
Caching Per Query vs Globally
You have two ways to decide what gets cached. Per-query is the explicit, safe default: append .Cacheable(...) to any LINQ query and pass an expiration mode and duration.
var products = await context.Products .Where(p => p.IsActive) .OrderBy(p => p.Name) .Take(100) .Cacheable(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(5)) .ToListAsync(ct);CacheExpirationMode has three values: Absolute (expires after a fixed duration), Sliding (expires after a period of no access), and NeverRemove. The first call runs against the database; every call after that, from any request, is served from the cache until it expires or a write invalidates it.
The alternative is to cache everything by default and opt specific queries out:
builder.Services.AddEFSecondLevelCache(options => options .UseMemoryCacheProvider() .CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(30)) // don't cache volatile or per-user queries under the global policy .SkipCachingCommands(commandText => commandText.Contains("NEWID()", StringComparison.OrdinalIgnoreCase)));CacheAllQueries is convenient but dangerous - it caches every read, including ones you did not think about. I prefer per-query .Cacheable() so caching is a deliberate decision per read path, not a blanket default that quietly caches something it shouldn’t.
How Do I Use Redis as a Second-Level Cache?
Swap the provider package. Everything else stays the same. For a single-process app the in-memory provider is fine, but the moment you run more than one instance, you need a shared cache like Redis so an invalidation on one node is seen by all of them (more on that in the invalidation section).
dotnet add package EFCoreSecondLevelCacheInterceptor.StackExchange.Redisbuilder.Services.AddEFSecondLevelCache(options => options .UseStackExchangeRedisCacheProvider("localhost:6379", TimeSpan.FromMinutes(5)) .ConfigureLogging(true) .UseCacheKeyPrefix("EF_"));Note the Redis provider signature: it takes the connection string and a TimeSpan positionally, with no CacheExpirationMode overload like the in-memory and HybridCache providers have. The parameter is named timeout, but it is not a connection or network timeout. It is the cache expiration duration, so the five minutes above is how long a cached result lives in Redis. If you are already on .NET 10’s HybridCache for its L1-plus-L2 model with stampede protection, there is a dedicated EFCoreSecondLevelCacheInterceptor.HybridCache package with UseHybridCacheProvider() - register AddHybridCache() first, then point the interceptor at it.
HybridCache in ASP.NET Core
HybridCache gives you L1 in-memory plus an optional L2 like Redis, with stampede protection built in.
Second-Level Cache Benchmark: How Much Does a Hit Save?
I benchmarked one repeated read - Products.Where(p => p.IsActive).OrderBy(p => p.Name).Take(100) against 5,000 seeded rows - three ways: straight to the database with no cache, served from the in-memory second-level cache, and served from the Redis second-level cache, using BenchmarkDotNet. Absolute numbers depend on your hardware, query cost, and network; the relative shape is the durable result.
| Approach | Mean | vs database |
|---|---|---|
| Uncached (database round trip) | 1,440 µs | baseline |
| In-memory cache hit | 129 µs | ~11x faster |
| Redis cache hit | 569 µs | ~2.5x faster |
Measured with BenchmarkDotNet 0.15.4 on .NET 10, EF Core 10,
EFCoreSecondLevelCacheInterceptor5.5.0, PostgreSQL 17 and Redis 7 in Docker (local), 3 warmups and 10 iterations. The database query here is a fast, indexed, local read at ~1.4 ms - the win grows on slower or remote databases and more expensive queries. Note the cache does not reduce allocations (the in-memory hit allocated ~160 KB vs ~137 KB uncached, because it deserializes the cached rows); the win is latency and avoided database load, not memory.
The in-memory hit avoids the query translation, the round trip, and the materialization entirely, so it lands far below the database read. The Redis hit pays a network hop plus serialization, so it is slower than in-memory, but it is still well under the database round trip and it is shared across every instance. The honest caveat: this win is real only when the query is repeated often and the underlying read is expensive or remote. A cheap indexed query on a fast local database is already quick, and a Redis hit can approach its cost. Caching does nothing for writes, and a low hit rate is a net loss once you count the lookup, serialization, and invalidation overhead.
Why Does a Cached Query Return Stale Data?
Because a write slipped past the auto-invalidation. This is the single most important thing to understand about a transparent second-level cache, and it is where teams get burned. The interceptor invalidates correctly for SaveChanges: on save it inspects the change tracker, sees which tables changed, and evicts every cached query that touches them. That covers normal entity writes.
It does not cover writes that bypass the change tracker:
ExecuteUpdateandExecuteDelete- these issue SQL directly without going throughSaveChanges, so the interceptor never sees them. The cache keeps serving the old rows.- Raw SQL (
ExecuteSqlRaw,ExecuteSqlInterpolated) - same problem. - Another service, a database job, or a stored procedure writing to the same tables - the library has zero visibility into those.
In every one of those cases, your cached reads stay stale until the TTL expires. The fix is to invalidate manually. Inject IEFCacheServiceProvider and clear the affected table after the write:
app.MapPost("/products/deactivate-old", async ( AppDbContext context, IEFCacheServiceProvider cache, CancellationToken ct) =>{ var affected = await context.Products .Where(p => p.CreatedAtUtc < DateTime.UtcNow.AddYears(-1)) .ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false), ct);
// ExecuteUpdate bypasses SaveChanges, so clear the cache yourself // or /products keeps serving the deactivated rows until the TTL. cache.InvalidateCacheDependencies( new EFCacheKey(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "EF_Products" }));
return Results.Ok(new { Deactivated = affected });});Watch the EF_ prefix on the table name, because this is the detail that makes manual invalidation fail silently. The library stores each cached query’s dependencies as the cache key prefix plus the table name, so with UseCacheKeyPrefix("EF_") the stored dependency is EF_Products, not Products. Pass the bare table name and nothing matches, the call throws no error, and your reads stay stale exactly as if you had never invalidated. Whatever you pass to UseCacheKeyPrefix must be on the front of every dependency you pass here.
IEFCacheServiceProvider.ClearAllCachedEntries() nukes the whole cache if you’d rather not track dependencies, and it sidesteps the prefix trap entirely. The rule to internalize: any write the change tracker doesn’t see is a write the cache doesn’t see. If a code path uses ExecuteUpdate/ExecuteDelete or raw SQL on a cached table, it owns a manual invalidation.
Bulk Operations in EF Core 10
ExecuteUpdate and ExecuteDelete are the bulk operations that bypass the change tracker, and therefore the cache.
The Multi-Instance Trap
There is a second staleness trap that only shows up in production. An in-memory second-level cache is per-instance. When you scale out to three pods, a SaveChanges on pod A evicts pod A’s local cache, but pods B and C keep serving stale reads from their own caches. Your QA box passes because it is a single instance; production serves data that is minutes out of date.
The fix is a shared cache: use the Redis provider (or HybridCache with a distributed L2 and a backplane) so invalidation propagates across every instance. If you run more than one replica, an in-memory second-level cache is not safe for anything that must be consistent.
The Multi-Tenant Trap
Because the cache key is the generated SQL plus its parameters, a multi-tenant app has a sharp edge: if your tenant discriminator is applied through a global query filter that is not a real SQL parameter, two tenants can generate the same cache key and one tenant’s rows get served to another. This is a data-leak bug, not just a staleness bug.
There are two fixes and you should use both. First, confirm the tenant identifier is actually a parameter in the generated SQL, not a constant baked into the query text. Second, make the tenant part of the cache key itself. UseCacheKeyPrefix has an overload that takes a Func<IServiceProvider, string> and is evaluated per request, so you can namespace every key by tenant:
builder.Services.AddEFSecondLevelCache(options => options .UseMemoryCacheProvider() .UseCacheKeyPrefix(sp => "EF_" + sp.GetRequiredService<ITenantContext>().TenantId + "_"));Now a key collision between tenants is impossible by construction, because tenant A’s keys never share a prefix with tenant B’s. Remember that this prefix also flows into the dependency names, so manual invalidation has to use the same per-tenant prefix.
Global Query Filters in EF Core
Multi-tenant apps apply the tenant filter as a global query filter. Know how filters translate to SQL before you cache.
When Should You Not Use Second-Level Caching?
Cache read-heavy, rarely-changing data. Skip everything else. A second-level cache pays off for reference and lookup data - categories, countries, configuration, product catalogs - and for expensive aggregations that tolerate mild staleness. It is the wrong tool for anything volatile or sensitive.
Do not cache:
- Volatile data - if it changes constantly, you invalidate constantly and hit the cache rarely, paying overhead for almost no hits.
- Per-user or per-tenant sensitive data - risk of key collision and cross-user leakage, plus a low hit rate.
- Anything where a stale read is a correctness or compliance failure - account balances, inventory at checkout, authorization decisions.
- Non-deterministic queries - anything using
DateTime.Now,Guid.NewGuid(),RANDOM(), or an unstableTake()caches one snapshot and serves it wrong on the next call. UseSkipCachingCommandsto exclude these.
10 EF Core Performance Mistakes (and How to Fix Them)
A slow read is often a missing index or an N+1 query. Fix those first, before reaching for a cache.
Second-Level Cache vs HybridCache vs Manual Caching
The question I get most is “why not just use HybridCache directly?” All three approaches cache reads; they differ in how much control you trade for how much convenience. Here is how I choose:
| EF second-level cache | Manual cache-aside (HybridCache / IMemoryCache) | Output caching | |
|---|---|---|---|
| What it caches | Query result sets, keyed by SQL | Whatever you put in, keyed by semantic keys | Whole HTTP responses |
| Invalidation | Automatic on SaveChanges (not bulk/raw) | You write it - precise, covers every path | By tag or duration |
| Control | Low - transparent, SQL-keyed | High - you own keys, TTLs, what’s cached | Medium |
| Best for | Read-heavy reference data, minimal code | Precise invalidation, caching DTOs, covering bulk/external writes | Read endpoints identical across users |
| Main risk | Stale on bulk/raw/external writes | More code to write and maintain | Coarse-grained |
My take: reach for the EF second-level cache when you want to speed up a read-heavy app with reference data and the least code, and you can accept manual invalidation on your bulk write paths. Reach for manual cache-aside with HybridCache when invalidation must be precise, when you want to cache projected DTOs rather than entity result sets, or when writes routinely go through ExecuteUpdate or another service - because there you control invalidation on every path instead of hoping the interceptor saw the write.
In-Memory Caching in ASP.NET Core
Manual cache-aside with IMemoryCache is the alternative to a transparent second-level cache. Pattern, keys, and eviction.
How Do I Verify the Cache Is Actually Hitting?
Turn on the library’s logging and watch for its cache-hit events. ConfigureLogging(true) makes the interceptor log whether each query was a cache hit or went to the database, which is the fastest way to confirm your .Cacheable() calls work and to catch queries you thought were cached but aren’t. Pair that with your database query metrics: if a read path is cached correctly, its query count should drop to near zero after warmup.
Distributed Caching in ASP.NET Core with Redis
Running the Redis provider? This covers Redis setup, IDistributedCache, and the operational side.
Key Takeaways
- EF Core 10 has no built-in second-level cache. The first-level cache is the change tracker (an identity map per
DbContext); onlyFind/FindAsyncskips a round trip, not ordinary LINQ queries. - Add one with
EFCoreSecondLevelCacheInterceptorv5.5.0. In v5 the provider is a separate package - install.MemoryCache,.StackExchange.Redis, or.HybridCachealongside the core package. - Cache per-query with
.Cacheable(...)or globally withCacheAllQueries(...). Per-query is the safer default. - The auto-invalidation only covers
SaveChanges.ExecuteUpdate,ExecuteDelete, raw SQL, and external writers leave the cache stale until TTL - invalidate them manually viaIEFCacheServiceProvider. - In-memory second-level cache is per-instance. Scaled out, you need Redis or HybridCache so invalidation propagates. Cache read-heavy reference data; never cache volatile or per-user data.
Troubleshooting
UseMemoryCacheProvider() does not exist / no provider method found
You are on v5 and only installed the core package. Install the provider package too: EFCoreSecondLevelCacheInterceptor.MemoryCache (or .StackExchange.Redis / .HybridCache). In v5 providers are separate NuGet packages.
Cached query still returns old data after an update
The write bypassed the change tracker. ExecuteUpdate, ExecuteDelete, and raw SQL do not trigger auto-invalidation. Inject IEFCacheServiceProvider and call InvalidateCacheDependencies(...) or ClearAllCachedEntries() after the write.
Cache works locally but serves stale data in production
You are running multiple instances with the in-memory provider, which is per-instance. Switch to the Redis or HybridCache provider so an eviction on one node reaches the others.
The cache never seems to hit
Two usual causes. Either your query parameters vary every call (a timestamp or a Guid), so every call produces a different cache key, or your reads run inside an explicit transaction. The library disables caching for queries in an explicit transaction by default, so a repository that wraps everything in BeginTransaction will never see a hit. Turn on ConfigureLogging(true) to confirm which one it is, then either stabilise the parameters or opt in with AllowCachingWithExplicitTransactions(true).
InvalidateCacheDependencies runs but the data is still stale
The table name you passed is missing the cache key prefix. Dependencies are stored as prefix plus table name, so with UseCacheKeyPrefix("EF_") you must pass EF_Products, not Products. Nothing throws when it does not match, so the call looks like it worked.
One tenant sees another tenant’s data
Your tenant filter is not a real SQL parameter, so different tenants share a cache key. Ensure the tenant discriminator is parameterized before caching in a multi-tenant app.
FAQ
Does EF Core have a second-level cache built in?
No. EF Core 10 has no built-in second-level cache. Its first-level cache is the change tracker, scoped to a single DbContext, which resolves entity identity but does not cache query results. To cache query results across requests you need a third-party library such as EFCoreSecondLevelCacheInterceptor.
What is the difference between first-level and second-level cache in EF Core?
The first-level cache is EF Core's change tracker (identity map), scoped to one DbContext instance; it returns already-tracked entity instances by key, but ordinary LINQ queries still hit the database. A second-level cache stores query result sets in a shared cache across contexts and requests, so a repeated query is served from memory or Redis instead of the database. Only the first is built in.
How do I add second-level caching in EF Core 10?
Install the EFCoreSecondLevelCacheInterceptor core package plus a provider package (MemoryCache, StackExchange.Redis, or HybridCache), register it with AddEFSecondLevelCache and a provider like UseMemoryCacheProvider, then add the SecondLevelCacheInterceptor to your DbContext via AddInterceptors. Cache queries with the Cacheable extension method or globally with CacheAllQueries.
How does EFCoreSecondLevelCacheInterceptor invalidate the cache?
On SaveChanges it inspects the change tracker, determines which tables were modified, and evicts every cached query that touches those tables. That is automatic for normal entity writes, but it does not cover writes that bypass the change tracker, which require manual invalidation.
Does the cache invalidate on ExecuteUpdate or ExecuteDelete?
No. ExecuteUpdate and ExecuteDelete issue SQL directly without going through SaveChanges, so the interceptor never sees them and the cache stays stale until the TTL expires. The same applies to raw SQL and writes from other services. Inject IEFCacheServiceProvider and call InvalidateCacheDependencies or ClearAllCachedEntries after such writes. Dependencies are stored as the cache key prefix plus the table name, so pass EF_Products rather than Products, otherwise the call silently matches nothing.
How do I use Redis as a second-level cache for EF Core?
Install the EFCoreSecondLevelCacheInterceptor.StackExchange.Redis provider package and register it with UseStackExchangeRedisCacheProvider, passing the Redis connection string and a TimeSpan that sets the cache expiration duration. Redis is required when you run more than one instance, because an in-memory second-level cache is per-instance and serves stale data on nodes that did not perform the write.
When should you not use second-level caching?
Do not use it for volatile data, per-user or per-tenant sensitive data, or anything where a stale read is a correctness or compliance failure such as balances, checkout inventory, or authorization. It also hurts when the hit rate is low, since you pay lookup, serialization, and invalidation overhead for few hits. Cache read-heavy, rarely-changing reference data instead.
Second-level cache vs HybridCache - which should I use in .NET 10?
Use the EF second-level cache to speed up read-heavy reference data with minimal code, when you can accept manual invalidation on bulk write paths. Use manual cache-aside with HybridCache when invalidation must be precise, when you want to cache DTOs rather than entity result sets, or when writes go through ExecuteUpdate or other services. HybridCache can also back the second-level cache as its distributed provider.
Summary
A second-level cache turns EF Core’s repeated reads into memory or Redis lookups, and EFCoreSecondLevelCacheInterceptor makes it a three-line setup on EF Core 10, as long as you install the right provider package for v5. The speed is real and the code is minimal, which is exactly why the invalidation caveats matter: every ExecuteUpdate, raw query, and external write is a manual invalidation you own (with the cache key prefix on the table name), and an in-memory cache across multiple instances will serve stale data until you move to Redis. Cache read-heavy reference data, keep volatile and per-user data out, and turn on logging so you can prove the cache is actually hitting.
Clone the sample repo, run the benchmark against your own schema, and watch the cached read drop below the database round trip.
Happy Coding :)
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.