Skip to main content

Command Palette

Search for a command to run...

7 Common C# LINQ Mistakes in ASP.NET Core (And How to Fix Them)

Discover the 7 most common C# LINQ mistakes in ASP.NET Core and how to fix them before they hit production.

Updated
โ€ข10 min read
7 Common C# LINQ Mistakes in ASP.NET Core (And How to Fix Them)

LINQ is one of the most expressive tools in the C# developer's arsenal โ€” and one of the most misused. In enterprise ASP.NET Core applications, LINQ mistakes don't just produce ugly code; they produce slow endpoints, exhausted thread pools, and queries that silently load far more data than you intended. Most of these mistakes look completely fine in development, then cause real pain under production load. The common C# LINQ mistakes covered here are the ones that consistently appear in code reviews, post-mortems, and performance investigations across real teams.

If you want to go deeper than theory, the production-ready implementations with real API context are available on Patreon โ€” including annotated source code that shows exactly how each of these fixes fits inside a working enterprise codebase.

Understanding LINQ's relationship with IQueryable and IEnumerable is at the heart of most of these mistakes. Chapter 4 of the ASP.NET Core Web API: Zero to Production course covers server-side filtering, sorting, and pagination inside a real API โ€” with IQueryable pipelines and PagedResult<T> implemented correctly from the ground up.

ASP.NET Core Web API: Zero to Production


Mistake 1: Materialising Too Early with .ToList() or .ToArray()

This is the most common LINQ mistake in ASP.NET Core codebases, and it compounds with every query you write. When you call .ToList() or .ToArray() before the pipeline is finished, you pull the entire result set into memory and switch from an IQueryable<T> (a composable database query) to an IEnumerable<T> (an in-memory collection). Any subsequent .Where(), .OrderBy(), or .Select() calls run in C# โ€” not in the database.

The result: queries that load thousands of rows when they only need ten.

The fix: Always compose your full LINQ expression first โ€” filtering, projecting, ordering, paging โ€” and then materialise exactly once at the very end. Think of .ToListAsync() (or .FirstOrDefaultAsync(), .CountAsync()) as the commit button. You should only press it once, and only after the pipeline is fully defined.

For a broader look at how this plays out in high-traffic read endpoints, the EF Core Performance Tuning Checklist for High-Traffic APIs walks through the most costly database patterns alongside LINQ composition.


Mistake 2: Confusing IEnumerable and IQueryable at Layer Boundaries

This mistake is architecturally dangerous. If a repository method returns IEnumerable<T>, the query has already been executed. Callers who then add .Where() or .OrderBy() clauses are filtering in-memory โ€” they just don't know it. If a method returns IQueryable<T>, the query is still composable at the database level, but the caller is now tightly coupled to EF Core and can accidentally trigger execution at an unexpected point.

The impact shows up in service layers that look correct but send catastrophically expensive SQL.

The fix: Be deliberate about which interface you expose. Repositories should generally materialise their results (IReadOnlyList<T>) and accept query parameters (a ProductQueryParams object, for example) rather than leaking IQueryable<T> across layers. When you do need composability at the service layer, keep the IQueryable scoped within the same unit of work and materialise before returning anything.

The IEnumerable vs IQueryable vs IAsyncEnumerable in .NET guide covers the decision framework in detail.


What Does "Deferred Execution" Actually Mean โ€” and Why Does It Matter in APIs?

Deferred execution means a LINQ query does not run until you iterate it or call a materialising operator. This is intentional and powerful โ€” it lets you build up query pipelines incrementally without hitting the database repeatedly. But in an API context, deferred execution has a concrete implication that trips up many developers: the query executes when you enumerate the result, not when you define it.

If your DbContext is disposed before the query enumerates โ€” for example, because you returned an IEnumerable<T> or IQueryable<T> from a method and the caller iterates it after the using block ends โ€” you'll get an ObjectDisposedException at runtime. The fix is the same as above: materialise explicitly, inside the scope where the context is alive.


Mistake 3: Using .Count() When You Only Need .Any()

// โŒ Loads and counts all matching rows
if (_context.Orders.Where(o => o.CustomerId == id).Count() > 0) { ... }

// โœ… Stops at the first match
if (await _context.Orders.AnyAsync(o => o.CustomerId == id)) { ... }

.Count() forces the database to scan and count every matching row. .Any() tells the database to stop as soon as it finds one match โ€” which is a SELECT TOP 1 or EXISTS query under the hood. At scale, this difference is significant: on a table with millions of rows, .Count() > 0 can take seconds while .AnyAsync() returns in milliseconds.

The fix: Whenever you're checking existence (if count > 0, if count == 0), always use .Any() or .None() instead. Save .Count() for when you actually need the count.


Mistake 4: Chaining .Where() Calls That Aren't Composed

Multiple .Where() calls on an IQueryable<T> are perfectly fine โ€” EF Core composes them into a single WHERE clause in SQL. The mistake is writing conditional filter logic that bypasses that composition:

// โŒ Returns all rows when filter is null โ€” materialises before further filtering
var products = await _context.Products.ToListAsync();
if (searchTerm != null)
    products = products.Where(p => p.Name.Contains(searchTerm)).ToList();

This loads the entire table regardless of the filter. The conditional check happens in C# on an already-materialised list.

The fix: Build the IQueryable pipeline conditionally before materialising:

// โœ… Filter is applied before the query executes
var query = _context.Products.AsQueryable();
if (searchTerm != null)
    query = query.Where(p => p.Name.Contains(searchTerm));
var products = await query.ToListAsync();

This pattern is especially important for search, filtering, and paginated list endpoints โ€” where the combination of active filters determines how expensive the query becomes.


Mistake 5: Projecting Too Late (or Not at All)

Loading full entity objects when you only need three properties is one of the most consistent sources of wasted database I/O in enterprise APIs. If your endpoint returns a summary DTO with five fields and your entity has forty, you're loading eight times more data than the response needs.

This matters even more when entities have navigation properties. If you load a Customer entity without a .Select(), and the mapper then accesses customer.Orders.Count, you may trigger a lazy load or an N+1 query you didn't anticipate.

The fix: Add a .Select() to every query that feeds a response DTO. Project to the exact shape you need at the database level โ€” not after materialisation. This reduces the SQL payload, reduces allocations, and eliminates entire categories of unexpected navigation property loading.


Mistake 6: Ignoring CancellationToken in Async LINQ Operations

Every materialising async LINQ method โ€” .ToListAsync(), .FirstOrDefaultAsync(), .AnyAsync(), .CountAsync(), .SumAsync() โ€” accepts a CancellationToken. Most ASP.NET Core applications never pass one.

The consequence: when a client disconnects or a request times out, the in-flight database query continues running until it completes naturally. Under load, this means cancelled requests continue consuming database connections, query threads, and memory โ€” contributing to thread pool pressure and connection pool exhaustion, especially on slow or large queries.

The fix: Accept CancellationToken cancellationToken in every controller action, service method, and repository method, and thread it through to every async LINQ call. ASP.NET Core provides the request's cancellation token via HttpContext.RequestAborted, and controller actions receive it automatically if you add it as a method parameter.


Mistake 7: Using LINQ for Bulk Mutations

LINQ is a query language. It is not a mutation mechanism. A surprisingly common pattern in enterprise codebases is loading a collection, iterating it with a foreach, mutating each entity, and then calling SaveChangesAsync(). For small datasets this is fine. For hundreds or thousands of records, you're making one round-trip per entity, or at best relying on EF Core change tracking to batch inserts โ€” which is still far slower than a set-based operation.

The same mistake happens with deletes: loading entities into memory in order to call _context.Remove() on each one is unnecessary work when a single SQL DELETE WHERE would do it in one round-trip.

The fix: Use ExecuteUpdateAsync() and ExecuteDeleteAsync() (EF Core 7+) for bulk mutations that don't require entity-level logic. These emit a single parameterised SQL statement and bypass change tracking entirely. For anything requiring per-entity business rules, process in batches rather than one at a time, and consider whether the logic belongs in a SQL stored procedure or a background job instead of a synchronous HTTP handler.


โ˜• Prefer a one-time tip? Buy us a coffee โ€” every bit helps keep the content coming!


FAQ

What is the difference between IEnumerable and IQueryable in LINQ?

IEnumerable<T> executes in memory โ€” once the data is loaded, all filtering and sorting runs in C#. IQueryable<T> is composable: LINQ operations build an expression tree that is translated into SQL (or another query language) and executed at the source. In ASP.NET Core APIs backed by EF Core, you almost always want IQueryable<T> at the point of composition, and IEnumerable<T> (or a concrete list) only after materialisation.

Why does calling .Count() > 0 hurt performance in ASP.NET Core?

.Count() generates a SELECT COUNT(*) query that scans all matching rows. .Any() generates an EXISTS or SELECT TOP 1 query that stops at the first match. On large tables or under high concurrency, the difference in execution time and database I/O is significant. Always use .AnyAsync() for existence checks.

What does "deferred execution" mean in LINQ, and when is the query actually sent to the database?

With IQueryable<T>, the query is not sent to the database when you define it โ€” it is sent only when you materialise it by calling a terminal operator like .ToListAsync(), .FirstOrDefaultAsync(), .CountAsync(), or by iterating with foreach. This lets you compose filters and projections incrementally before committing the query.

How does missing a .Select() projection affect API performance?

Without a projection, EF Core loads the full entity โ€” all columns, and potentially related data if navigation properties are accessed. If your API only returns a subset of fields, you're loading and allocating far more data than needed, increasing database I/O, network payload, and memory pressure. Always project to your response DTO at the database level using .Select().

Why should I pass CancellationToken to async LINQ methods?

When a client disconnects or a request is cancelled, passing the CancellationToken allows EF Core to cancel the in-flight database query immediately. Without it, the query continues running to completion even though the result will never be used โ€” wasting database connections, thread pool threads, and memory under load.

Is it safe to return IQueryable from a repository method?

Generally no. Returning IQueryable<T> from a repository leaks EF Core details through your architecture and means the query can be modified or executed unpredictably by callers. The preferred pattern is to accept explicit query parameters in the repository, build the full query internally, and return a materialised result. This keeps query logic contained and prevents accidental performance regressions from callers modifying the pipeline.

When should I use ExecuteUpdateAsync or ExecuteDeleteAsync instead of loading entities?

Use ExecuteUpdateAsync() and ExecuteDeleteAsync() (EF Core 7+) whenever you need to update or delete a set of rows based on a condition, without needing to run per-entity business logic or fire domain events. These methods emit a single SQL statement and bypass change tracking, making them dramatically faster for bulk operations. For per-entity business rules, load entities but process in batches โ€” and always evaluate whether the operation belongs in a background job rather than a synchronous request handler.

More from this blog

C

Coding Droplets

220 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.