LINQ performance traps
LINQ is elegant until you realize your query just ran 500 times when it should have run once. In production, I've seen developers write perfectly valid LINQ that melts CPU and database connections under load. Today I want to walk you through the most common LINQ performance traps I encounter on real projects, and show you exactly how to avoid them.
The multiple enumeration problem
Every time you enumerate an IEnumerable, you re-run the entire operation. This is the single biggest LINQ gotcha I see in code reviews. Let me show you a real scenario from a ride-hailing system I built.
// WRONG: Enumerating multiple times
var activeRides = _context.Rides
.Where(r => r.Status == RideStatus.Active)
.Where(r => r.CreatedAt > DateTime.UtcNow.AddHours(-1));
var count = activeRides.Count();
var recentRides = activeRides.Take(10).ToList();
// If using IEnumerable from a method, this is even worse:
var drivers = GetAvailableDrivers(); // Returns IEnumerable
var needsCertification = drivers.Where(d => !d.HasBackgroundCheck).Count();
var driverList = drivers.Where(d => !d.HasBackgroundCheck).ToList();
// ^ This filter runs TWICE
Why is this a problem? With an IEnumerable backed by a database query (via LINQ to SQL or Entity Framework), each enumeration translates to a database round trip. With in-memory LINQ, you're re-filtering the collection unnecessarily. In the drivers example above, if you have 10,000 drivers and call the method twice, you're filtering 20,000 items instead of 10,000.
The fix is simple: materialize once with ToList(), then enumerate as many times as you need.
// CORRECT: Enumerate once, reuse multiple times
var activeRides = _context.Rides
.Where(r => r.Status == RideStatus.Active)
.Where(r => r.CreatedAt > DateTime.UtcNow.AddHours(-1))
.ToList(); // Materialize HERE
var count = activeRides.Count; // No query execution
var recentRides = activeRides.Take(10).ToList(); // No query execution
This is especially critical in loops. I once found code that was calling .Count() on the same IEnumerable inside a loop—materializing the same dataset hundreds of times. Moving ToList() outside the loop cut query time from 8 seconds to 80 milliseconds.
Any() is faster than Count() > 0
This is a micro-optimization, but it matters at scale. When you only care whether a collection has at least one item, use Any(), not Count() > 0.
// WRONG: Counts ALL items just to check if any exist
var hasActiveBookings = _context.Bookings
.Where(b => b.UserId == userId)
.Where(b => b.Status == BookingStatus.Active)
.Count() > 0;
// CORRECT: Stops after finding the first item
var hasActiveBookings = _context.Bookings
.Where(b => b.UserId == userId)
.Where(b => b.Status == BookingStatus.Active)
.Any();
Here's why it matters. With a database backend, Count() translates to SELECT COUNT(*), which scans the entire result set. Any() translates to SELECT TOP 1 or LIMIT 1—it stops immediately after finding one match. On a table with 1 million bookings, this difference is massive.
In-memory, Any() short-circuits as soon as it finds the first matching element. Count() always inspects every element. I've seen developers write if (items.Count() > 0) { ... } when they really meant if (items.Any()) { ... }.
// In-memory example
var largeList = Enumerable.Range(1, 1_000_000).ToList();
// This iterates all 1 million items
var countCheck = largeList.Where(x => x > 500_000).Count() > 0; // ~500k iterations
// This stops after finding the first match
var anyCheck = largeList.Where(x => x > 500_000).Any(); // 1 iteration
IQueryable vs IEnumerable: know where filtering happens
This is where LINQ really bites you. IQueryable filtering runs on the database. IEnumerable filtering runs in memory. Mixing them up is how you accidentally load an entire table into RAM.
// WRONG: Loads ALL users into memory, then filters in-process
IEnumerable<User> allUsers = _context.Users; // IEnumerable
var premiumUsers = allUsers
.Where(u => u.Subscription == SubscriptionType.Premium)
.ToList();
// If you have 10 million users, this loads all 10 million into RAM before filtering
// CORRECT: Filter at database level
IQueryable<User> query = _context.Users; // IQueryable
var premiumUsers = query
.Where(u => u.Subscription == SubscriptionType.Premium)
.ToList();
// Translates to: SELECT * FROM Users WHERE Subscription = 'Premium'
The difference is huge. I worked on a CRM where someone added a .AsEnumerable() in the middle of a filtering chain. Suddenly a query that used to take 50ms started timing out. We were loading 500,000 contacts into memory just to filter a few thousand.
// DANGEROUS: Extra AsEnumerable() causes everything after it to run in-process
var results = _context.Contacts
.Where(c => c.Company == "Acme") // Runs at DB (good)
.AsEnumerable() // SWITCHES TO IN-PROCESS
.Where(c => c.Email.Contains("@gmail")) // Runs in memory (bad if Gmail is common)
.Where(c => c.CreatedAt > DateTime.UtcNow.AddYears(-1))
.ToList();
Why would this happen? Usually because someone needs a complex filter that doesn't translate to SQL (like calling a custom method or parsing a date with specific formatting). My rule: keep filtering in the database as long as possible. Only switch to in-memory when you absolutely need to.
// BETTER: Filter at DB first, then apply complex logic
var contacts = _context.Contacts
.Where(c => c.Company == "Acme")
.Where(c => c.Email.Contains("@gmail"))
.Where(c => c.CreatedAt > DateTime.UtcNow.AddYears(-1))
.ToList(); // Materialize first
// THEN apply in-memory logic if needed
var fullyProcessed = contacts
.Where(c => MyComplexEmailValidation(c.Email))
.ToList();
ToList() once, then reuse
This ties everything together. Once you've materialized your data with ToList(), you have a concrete collection. Use it multiple times instead of re-querying.
// Pattern from my travel booking system
var availableFlights = _context.Flights
.Where(f => f.DepartureCity == "CMB")
.Where(f => f.ArrivalCity == "DXB")
.Where(f => f.DepartureDate == searchDate)
.ToList(); // Materialize once
// Now reuse the list without additional queries
var cheapestFlight = availableFlights.OrderBy(f => f.Price).First();
var fastestFlight = availableFlights.OrderBy(f => f.Duration).First();
var flightCount = availableFlights.Count;
var groupedByAirline = availableFlights.GroupBy(f => f.Airline);
// All of these operations work on the in-memory list
Compare this to fetching separately:
// WRONG: Multiple database queries for related data
var cheapestFlight = _context.Flights
.Where(f => f.DepartureCity == "CMB" && f.ArrivalCity == "DXB" && f.DepartureDate == searchDate)
.OrderBy(f => f.Price)
.First();
var fastestFlight = _context.Flights
.Where(f => f.DepartureCity == "CMB" && f.ArrivalCity == "DXB" && f.DepartureDate == searchDate)
.OrderBy(f => f.Duration)
.First();
// That's 2 queries when 1 would do
Common mistakes
- Forgetting that LINQ methods return new enumerables. Chaining methods doesn't automatically materialize. Each enumeration still re-runs the chain. Call
ToList(),ToDictionary(), orToArray()to actually execute and cache. - Using Count() to check existence. Always use
Any()when you only care if something exists. Count() touches every item; Any() stops at the first match. - Mixing IQueryable and IEnumerable without thinking. If you're not sure which you have, check the type. If it's IQueryable, filtering happens at the database. If it's IEnumerable, filtering happens in memory.
- Calling AsEnumerable() accidentally or carelessly. This is often buried in extension methods. It forces everything after it into memory, which kills performance on large datasets.
- Re-querying inside loops. Build the collection once, then iterate. If you're executing a query per item, that's an N+1 problem waiting to happen.
Key takeaways
- Enumerate once. Each enumeration of an
IEnumerablere-runs the work—especially with database queries. - Use
Any()instead ofCount() > 0to check existence. It short-circuits after the first match. IQueryablefilters run at the database.IEnumerablefilters run in memory. Know which you have and where the work happens.- Call
ToList(),ToArray(), orToDictionary()once to materialize, then reuse the collection. This prevents re-execution and gives you a predictable in-memory collection. - Keep filtering in the database as long as SQL can express it. Only switch to in-memory LINQ when you need custom logic that doesn't translate to SQL.
- Always profile before and after. LINQ is powerful enough that small mistakes compound at scale.






Comments
Loading comments…