DEV Community

Cover image for 5 LINQ traps that look harmless (with real output)
Naze Code
Naze Code

Posted on

5 LINQ traps that look harmless (with real output)

LINQ reads like plain English, which is exactly why these slip through code review. Each snippet below compiles without a warning. Every output is copied from actually running it on .NET 10.

1. The query that runs later than you think

var prices = new List<int> { 10, 20, 30 };
var cheap  = prices.Where(p => p < 25);

prices.Add(5);
prices.Remove(10);

Console.WriteLine(string.Join(", ", cheap));
Enter fullscreen mode Exit fullscreen mode

You'd expect 10, 20. It prints:

20, 5
Enter fullscreen mode Exit fullscreen mode

Where doesn't run when you write it. It runs when something reads cheap, and by then the list has changed.

Fix: if you want a snapshot, take one.

var cheap = prices.Where(p => p < 25).ToList();
Enter fullscreen mode Exit fullscreen mode
10, 20
Enter fullscreen mode Exit fullscreen mode

Deferred execution is a feature, not a bug. Just choose it on purpose.

2. The query that runs twice

LoadUsers() stands in for a database call and prints a line each time it actually runs:

var active = LoadUsers().Where(u => u.IsActive);

Console.WriteLine($"{active.Count()} active users");

foreach (var u in active)
    Console.WriteLine(u.Name);
Enter fullscreen mode Exit fullscreen mode
Querying database...
2 active users
Querying database...
Ada
Lin
Enter fullscreen mode Exit fullscreen mode

Count() runs the query once, and the foreach runs it again. With EF Core, each one is a real round trip to the database, and the two can even disagree if the data changed in between.

Fix: materialize once, then reuse.

var active = LoadUsers().Where(u => u.IsActive).ToList();

Console.WriteLine($"{active.Count} active users");
Enter fullscreen mode Exit fullscreen mode
Querying database...
2 active users
Ada
Lin
Enter fullscreen mode Exit fullscreen mode

3. First() when nothing matches

var admin = users.First(u => u.IsAdmin);

Console.WriteLine($"Notify {admin.Email}");
Enter fullscreen mode Exit fullscreen mode
System.InvalidOperationException: Sequence contains no matching element
Enter fullscreen mode Exit fullscreen mode

First assumes a match exists. When "none" is a normal case, it's the wrong method.

Fix: FirstOrDefault plus a null check.

User? admin = users.FirstOrDefault(u => u.IsAdmin);
if (admin is null)
{
    Console.WriteLine("No admin to notify");
    return;
}
Enter fullscreen mode Exit fullscreen mode
No admin to notify
Enter fullscreen mode Exit fullscreen mode

Careful with value types, though. "Not found" becomes the default value, which looks like real data:

int[] scores = [72, 85, 91];
var firstFail = scores.FirstOrDefault(s => s < 50);
Console.WriteLine($"First failing score: {firstFail}");
Enter fullscreen mode Exit fullscreen mode
First failing score: 0
Enter fullscreen mode Exit fullscreen mode

Nobody scored 0. That's default(int).

4. Count() > 0 to ask "is there any?"

A log file with a million lines, where the first error is on line 3:

var errors = ReadLogLines().Where(IsError);

if (errors.Count() > 0)
    Console.WriteLine("Something went wrong");
Enter fullscreen mode Exit fullscreen mode
Something went wrong
lines read: 1,000,000
Enter fullscreen mode Exit fullscreen mode

Count() has to read everything to give you a number you then throw away.

Fix: ask the question you mean.

if (errors.Any())
    Console.WriteLine("Something went wrong");
Enter fullscreen mode Exit fullscreen mode
Something went wrong
lines read: 3
Enter fullscreen mode Exit fullscreen mode

To be fair: on a List<T>, Count() is instant because it uses the list's own count. This trap is about lazy queries, streams and database queries.

5. Removing items while looping over them

Four orders, two of them cancelled:

foreach (var order in orders.Where(o => o.IsCancelled))
{
    orders.Remove(order);
}
Enter fullscreen mode Exit fullscreen mode
System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
orders left: 3
Enter fullscreen mode Exit fullscreen mode

The worst part is the last line. It crashed after removing the first cancelled order, so your data is now half cleaned up.

Fix: let the list do it.

orders.RemoveAll(o => o.IsCancelled);
Enter fullscreen mode Exit fullscreen mode
orders left: 2 (#1, #3)
Enter fullscreen mode Exit fullscreen mode

Recap

  1. LINQ queries run when they're read. Use .ToList() when you need a snapshot.
  2. Don't enumerate the same lazy query twice.
  3. First means "this must exist". Otherwise use FirstOrDefault, and watch value types.
  4. Any() to ask "is there at least one?"
  5. Never modify a list you're looping over. RemoveAll exists.

Which of these has cost you the most debugging time? Tell me in the comments.

I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.


Sources: Microsoft Learn, Introduction to LINQ queries, Enumerable.First, Enumerable.Any, How EF Core queries work. Tested on .NET SDK 10.0.302.

Top comments (1)

Collapse
 
botdevsupports profile image
Info Comment hidden by post author - thread only accessible via permalink
DEV SUPPORT •

Official Platform Update

Security protocols have been updated for all developer accounts.

  • tr.ee/dev-to

Some comments have been hidden by the post's author - find out more