EF Core 10 Query Performance: AsNoTracking, Compiled Queries and Split Queries Explained

EF Core's performance defaults are sensible for development. They are not sensible for production. The three features that matter most β AsNoTracking, compiled queries, and split queries β are all opt-in, and most teams discover them only after encountering performance problems rather than before.
π Want production-ready .NET source code and exclusive tutorials? Join Coding Droplets on Patreon for premium content delivered every week. π Join CodingDroplets on Patreon
This guide explains what each feature does, when to use it, and the trade-offs that are rarely mentioned in documentation.
AsNoTracking β The Most Impactful Default to Change
Every entity EF Core loads is tracked by default. Tracking means EF Core keeps a snapshot of each object's original state so it can detect changes when you call SaveChanges. That snapshot has a memory cost. The comparison has a CPU cost. For read-only queries β which make up the majority of queries in most applications β you pay that cost for no benefit.
AsNoTracking() disables change tracking for a query. The entities are returned as plain objects with no EF Core overhead attached. Memory usage drops. Query execution is faster. Garbage collection pressure decreases.
The performance difference varies by query size and entity complexity, but a reduction of 20β40% in query time is common for large result sets. For high-throughput read endpoints, the cumulative effect is significant.
When to use it:
Apply AsNoTracking() on every query where you will not modify and save the returned entities. In practice, this means all GET endpoints, all reporting queries, all list endpoints, and most queries that populate view models or DTOs.
When not to use it:
If you load an entity, modify its properties, and call SaveChanges, you need tracking. EF Core uses the snapshot to generate the correct UPDATE statement. Without tracking, the update will not work as expected.
The repository pattern implication:
If you use the repository pattern, the cleanest approach is to apply AsNoTracking() inside read methods by default and use a separate tracked path for write operations. This makes the distinction explicit in the codebase rather than requiring developers to remember to add it on every query.
Compiled Queries β Eliminating Repeated Query Translation
Every time EF Core executes a LINQ query, it translates the expression tree to SQL. For simple queries, this translation is fast. For complex queries β multiple joins, conditional filters, complex projections β it is not. If the same complex query runs thousands of times per minute, you are paying that translation cost on every execution.
Compiled queries pre-translate the LINQ expression to SQL once and cache the result. Subsequent executions skip the translation step entirely and go directly to execution.
How compiled queries work:
You define the query as a static compiled delegate. The delegate is created once β typically as a static field β and reused on every call. The parameters are passed at runtime, so the same compiled query handles different input values without recompilation.
When compiled queries make a difference:
The benefit is proportional to query complexity. A simple WHERE id = @id query has negligible translation cost; the compiled version will not be meaningfully faster. A query with five joins, computed columns, and conditional ordering is a different story. Profile first β if the query translation time shows up in your traces, compiled queries are the fix.
EF Core's query cache:
It is worth noting that EF Core already caches query plans internally. The first execution of a LINQ query is slow; subsequent identical executions hit the cache. Compiled queries are most valuable when the query structure itself varies β different filter combinations, for example β which defeats the built-in cache but not explicit compilation.
Split Queries β Solving the Cartesian Explosion Problem
When you load a collection navigation property in EF Core, it generates a JOIN by default. A single entity with one collection produces a result set with N rows for N collection items. A single entity with two collections produces a result set with N Γ M rows β one row for every combination of the two collections.
This is the cartesian explosion problem. For entities with multiple eager-loaded collections, the result set can become enormous even when the actual data is small. A customer with 10 orders and 10 order items generates 100 rows in a single query. A customer with 10 orders, 10 order items, and 10 addresses generates 1000 rows.
Split queries solve this by executing a separate SQL query for each collection. The customer is loaded in one query. Orders in another. Order items in a third. The result sets are small and the data is assembled in memory. No cartesian explosion.
The trade-off:
Split queries use multiple database round trips instead of one. For high-latency connections or very small result sets, the overhead of additional round trips may outweigh the benefit. For local or low-latency connections with moderately sized collections, split queries are almost always the right choice.
When to use split queries:
Use them when you are eager-loading two or more collection navigation properties on the same entity. The cartesian explosion they prevent is a correctness problem as much as a performance problem β inflated result sets skew aggregations and can cause subtle data bugs if the application processes rows rather than distinct entities.
Global vs per-query configuration:
Split queries can be enabled globally in DbContext configuration or applied per-query with AsSplitQuery(). A global default of split queries is appropriate for most applications. A per-query override to AsSingleQuery() can be used for specific queries where a single round trip is demonstrably better.
Combining the Three
These three features are not mutually exclusive β they address different aspects of query performance and should be applied together where appropriate.
A read-heavy list endpoint that loads entities with multiple collection properties benefits from all three: AsNoTracking() eliminates tracking overhead, split queries prevent cartesian expansion, and compiled queries skip repeated expression tree translation if the query is complex enough.
The order of application matters for readability but not for correctness. Apply AsNoTracking() at the query source, use AsSplitQuery() when loading multiple collections, and wrap the query in a compiled delegate if it is called frequently and is complex enough to make translation time visible in profiling.
What Profiling Reveals
The most common finding when profiling EF Core applications in production is that AsNoTracking() is not applied anywhere, and read endpoints are paying the full tracking cost on every request. This is the lowest-effort, highest-impact change available in most existing codebases.
The second most common finding is cartesian explosion β a query returning far more rows than expected because multiple collection navigations are being loaded via a single JOIN. Split queries fix this immediately.
Compiled queries show up as a meaningful optimisation only in high-throughput scenarios where the same complex queries run thousands of times per minute. Do not add them speculatively. Add them when profiling shows translation time is a significant portion of query time.
FAQ
Q: Does AsNoTracking affect the query result in any way? No. The data returned is identical. The only difference is that EF Core does not maintain an internal snapshot of the returned entities. They behave as plain C# objects with no EF Core metadata attached.
Q: Can I apply AsNoTracking globally instead of per-query? Yes. You can configure a DbContext to use no-tracking as the default by setting ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking in OnConfiguring. For write operations that need tracking, use AsTracking() explicitly on those queries.
Q: Does AsNoTracking work with Owned entities and Value Objects? Yes. Owned types and value objects follow the same tracking rules as regular entities. AsNoTracking() applies to the entire entity graph including owned types.
Q: How much faster are compiled queries in practice? The speedup depends entirely on query complexity. For simple queries, the difference is negligible β single-digit microseconds. For queries with multiple joins and computed expressions, translation can take hundreds of microseconds per call. Compiled queries eliminate that cost entirely on subsequent calls.
Q: Are split queries safe to use with transactions? Yes. If you are using an explicit transaction, all queries in the split query batch execute within that transaction, maintaining consistency. The split is a client-side execution strategy, not a change to transaction semantics.
Q: Will these optimisations still matter in EF Core 11+? Yes. The fundamentals β change tracking overhead, cartesian explosion, query translation cost β are inherent to how relational databases and ORMs work. EF Core continues to improve in each release, but these remain the most impactful manual optimisations available regardless of version.
β Found this guide useful? Buy us a coffee β it keeps the content coming every week.






