LINQ in C#: Querying Collections the Idiomatic Way

Write idiomatic LINQ in C#: query vs method syntax, deferred execution, IEnumerable vs IQueryable, multiple enumeration traps, and allocation costs.

Executive Summary: LINQ in C# replaces manual loops over collections with declarative queries: say what you want, not how to get it. The engine behind every operator is one interface chain over IEnumerable<T>, and its most consequential property is that queries are lazy: a query defines work, and only enumeration runs it.

Every code review has a moment like this: forty lines of nested foreach with three temporary lists, an index counter, and a comment explaining the loop order, replaced by six lines of chained operators that read like the requirement. That is the argument for LINQ in one anecdote. The counterargument shows up in production a month later, when the same elegant chain enumerates an EF Core query twice and the database sees two identical round trips. Both behaviors come from the same feature.

LINQ in C# is the set of query operators in the System.Linq namespace: Where, Select, OrderBy, GroupBy, and dozens more, callable on any sequence. The examples use the recurring TaskApi domain (Projects, Tasks, Tags) and run on .NET SDK 10.0 with C# 14. If classes and records are new, the object-oriented C# guide covers them.

Fixing both beautiful and cursed LINQ taught me this: the operators are easy and the execution model is the whole exam. Learn deferred execution properly once, and the rest of LINQ stops being surprising in either direction.

Two syntaxes, one engine

Every LINQ query is written in one of two syntaxes, and the compiler turns them into the same calls. Query syntax is closest to SQL; method syntax chains extension methods:

// query syntax
var urgent =
    from task in tasks
    where task.DueDate < DateTime.UtcNow && !task.Done
    orderby task.DueDate
    select task;

// method syntax: identical result
var urgent = tasks
    .Where(task => task.DueDate < DateTime.UtcNow && !task.Done)
    .OrderBy(task => task.DueDate);

Method syntax covers more operators (First, Count, SelectMany have no query-syntax spelling), so most teams default to it and reach for query syntax only for complex join or let shapes. Pick per file, not per team meeting: consistency within a method matters more than the choice.

Deferred execution: the query runs when you enumerate

Here is the single behavior that explains most LINQ surprises. Building a query does nothing. The work runs when something enumerates it:

var tasks = new List<TaskItem> { /* three items */ };

var query = tasks.Where(t => !t.Done);   // nothing happened yet

tasks.Add(new TaskItem("Sneaks in late"));

Console.WriteLine(query.Count());      // 4: the filter sees the new item

query is a recipe, not a result. Each enumeration walks the source again, through every operator, lazily, element by element. That is what lets queries compose cheaply, and it is also what makes mutation between definition and enumeration visible. Microsoft documents the model as deferred execution and lazy evaluation, including how operators that return sequences differ from those that return single values.

Immediate execution: materialize when you mean it

Operators that return a value instead of a sequence force execution on the spot: Count(), First(), Any(), ToList(), ToArray(), ToDictionary(). ToList and friends are called materialization because they turn the recipe into a concrete, in-memory list:

var snapshot = tasks.Where(t => !t.Done).ToList();  // runs now

tasks.Add(new TaskItem("Sneaks in late"));

Console.WriteLine(snapshot.Count);   // still 3: the snapshot froze

The habit to build is deliberate. Chain lazily while composing, materialize exactly once at the point you need a stable result. That single discipline prevents the next section’s bug entirely.

Multiple enumeration: the classic trap

When a lazy query is enumerated twice, the work runs twice. With in-memory lists that costs some CPU. With an EF Core query it costs a second database round trip, and with a REST-backed IEnumerable it costs another HTTP call:

WRONG: the query runs at both lines.

var openTasks = _context.Tasks.Where(t => !t.Done);  // no SQL yet

Console.WriteLine(openTasks.Count());    // SQL round trip 1
foreach (var task in openTasks)          // SQL round trip 2
{
    Console.WriteLine(task.Title);
}

RIGHT: materialize once, then use the stable list.

var openTasks = _context.Tasks
    .Where(t => !t.Done)
    .ToList();                            // SQL round trip, one time

Console.WriteLine(openTasks.Count);
foreach (var task in openTasks)
{
    Console.WriteLine(task.Title);
}

Any time a method body consumes a query in two places (a Count and a loop, a validation and a return), that is the smell. Materialize once at the top and let both consumers read the list.

IEnumerable vs IQueryable: one chain, two worlds

Both interfaces inherit IEnumerable<T>, and both accept the same LINQ operators, which is exactly why they get confused. The difference is what happens to your lambda:

Aspect IEnumerable<T> (LINQ to Objects) IQueryable<T> (e.g. EF Core)
Your lambda becomes Compiled code An expression tree
Filter runs In your process, element by element In the database, as SQL
Calling ToList early Just fixes the snapshot Pulls every row into memory first
Non-translatable methods Always work Throw at runtime (InvalidOperationException)

An IQueryable carries your operators as data, so the provider can translate them. EF Core turns Where(t => !t.Done) into a SQL WHERE clause. The moment you call AsEnumerable() or ToList(), the sequence crosses the boundary back to objects, and every operator after it runs in memory.

The rule that keeps you out of trouble: stay on IQueryable for as long as the query remains translatable, materialize at the end, and never call ToList() before the filters. The EF Core code-first guide introduces the query side of the ORM, and querying efficiently with EF Core covers what the provider does with your chains, including the N+1 pattern.

The operators you actually use daily

Roughly ten operators cover almost all collection work. Here they are against the TaskApi domain:

var open = tasks.Where(t => !t.Done);              // filter

var titles = open.Select(t => t.Title);           // project

var newestFirst = tasks.OrderByDescending(t => t.CreatedAt);

var firstUrgent = open.FirstOrDefault(t => t.Tags.Contains("urgent"));

var anyOverdue = open.Any(t => t.IsOverdue());    // stops at first match

var tagNames = projects.SelectMany(p => p.Tags);  // flatten nested lists

var byProject = tasks.GroupBy(t => t.ProjectId);  // buckets

var total = tasks.Count(t => t.Done);             // filtered count

var pairs = tasks.ToDictionary(t => t.Id);        // key lookup

var summary = tasks.Aggregate(0, (acc, t) => acc + (t.Done ? 1 : 0));

Two of these get special attention. Any() answers “is there at least one” and stops at the first match, so prefer it over Count() > 0, which counts everything. SelectMany is the operator that flattens: projects to tags, orders to line items, parents to children. When a nested loop exists to iterate a inner list, SelectMany is usually the cleaner tool.

Allocation costs, honestly

Every chained operator allocates an enumerator object, and every lambda may allocate a closure when it captures local state. For request handling, batch jobs, and everything the TaskApi does, that cost is noise next to a database call or a network hop. In a tight loop over millions of items, it can matter, and there the real answer is to measure before changing code: BenchmarkDotNet is the standard test host. Its README alone teaches more about honest measurement than most performance blog posts.

The measured facts worth knowing without numbers: LINQ to Objects is slower than a hand-written foreach doing the same single pass. Still, the gap is proportional, not a different complexity class. The real LINQ disasters are not allocations. They are semantic: filtering a million-row table in memory because a ToList came too early, or enumerating an expensive query three times in one method. Optimize those first; they show up in every profiler.

How real systems do this

  • Mapping at the boundary. Services keep entities internal and expose records produced by Select: the response shape changes in one place, and the Web API guide shows the DTO contract this feeds. Payload types built as records, covered in the object-oriented C# guide, pair naturally with a final Select.
  • Materialize once, at the method’s edge. Repositories return already-materialized lists for the data the caller asked for, and composability stays inside the data layer where it can remain an IQueryable.
  • Profiler-verified hot paths. Teams rewrite only measured loops, and the rewritten loop keeps the name of the query it replaced so the intent survives the optimization.

A bug I have seen in production is a paginated endpoint whose LINQ looked clean and cost double: a Count() call for the total and a separate ToList() for the page, both over an IQueryable. As a result, every page request hit the database twice. The log made it obvious once someone looked; the fix was computing both from one materialized query. What I took from it: lazy queries hide their cost in the places you never see them, and log-based cost checks on endpoints are worth their minutes.

Decision framework: query, materialize, or loop

  1. Are you filtering, projecting, or grouping? That is LINQ’s home turf. A hand loop re-explains the intent every time someone reads it.
  2. Do you need the result more than once? Materialize once with ToList() and reuse the snapshot. Two consumptions of a lazy query means two executions.
  3. Are you on an IQueryable backed by a database? Keep filters translatable and materialize last. Call AsEnumerable() only for the part that must run in memory.
  4. Does the loop mutate external state or break early with complex exit logic? A foreach with clear statements beats a Select pretending to be pure.
  5. Has profiling flagged this exact loop? Only then trade readability for the hand-written pass. Keep the query version in a test that documents the expected behavior.

When NOT to reach for LINQ

  • When the loop has rich side effects. Logging, caching, and mutating several neighbors per iteration read as statements. Pushing them into lambdas makes a Select that lies about being a query. Keep the foreach and name it well.
  • When the source cannot be enumerated twice. Streams and network responses yield once. A lazy query over them that gets enumerated a second time throws ObjectDisposedException or simply returns nothing. Materialize immediately.
  • When the operator chain hides the algorithm. Three nested SelectMany with a conditional GroupBy is a sign the logic deserves a named method with tests, not a longer chain. If the query needs a paragraph to explain, it is code, not LINQ.

Common mistakes

  • Enumerating a lazy query multiple times. Each consumption re-runs the work, and on an IQueryable that is an extra database round trip per use. Materialize once when the result is needed twice.
  • Calling ToList before the filters. On an IQueryable, an early ToList pulls the whole table into memory and turns your SQL WHERE into an in-memory scan. Keep filtering on the queryable.
  • Count() when Any() answers the question. Count() walks the entire sequence. Any() stops at the first match, which matters on sequences that cost money to walk.
  • Ignoring what FirstOrDefault returns. It hands back null when nothing matches. Dereferencing the result without a check recreates the NullReferenceException you left dynamic languages to escape.
  • Side effects inside Where and Select. Operators look pure, and readers assume so. A lambda that writes state breaks that contract and breaks under deferred execution, because it runs later and possibly more than once.
  • Mutating the source between defining and enumerating. Deferred execution reads the source at enumeration time, so late additions and removals change the answer; if the timing matters, materialize before the mutation.

Key takeaways

  • Query syntax and method syntax compile to the same calls; method syntax covers more operators and is the working default.
  • A LINQ query is a recipe: it runs when enumerated, and it runs again on every enumeration.
  • Materialize deliberately with ToList, ToArray, or ToDictionary when a result is consumed more than once or must be stable.
  • IEnumerable runs your lambdas in memory. IQueryable translates them to the provider’s language, and ToList is the one-way door back to memory.
  • Prefer Any() over Count() > 0, and SelectMany for flattening nested collections.
  • Allocation costs are real but small. Semantic costs (double enumeration, in-memory filtering of database tables) are the expensive ones, and only profiling tells you when a loop must replace a query.

FAQ

What is the difference between query syntax and method syntax in LINQ?

They are two spellings of the same calls. The compiler translates query syntax into the same extension methods that method syntax chains. Query syntax suits complex joins and lets intermediate bindings, while method syntax covers every operator, including First, Count, and SelectMany. Most teams write method syntax and use query syntax where it reads better.

What is deferred execution in LINQ?

Building a query captures a recipe instead of a result: nothing runs until something enumerates the query, such as a foreach, Count, or ToList. Each enumeration runs the whole chain again over the current state of the source. This lets queries compose cheaply, and it is why consuming one twice means executing it twice.

What is the difference between IEnumerable and IQueryable in C#?

IEnumerable runs your lambdas as compiled code inside your process. IQueryable stores them as expression trees so a provider such as EF Core can translate them into SQL and run them in the database. Converting to a list with ToList or AsEnumerable moves you from IQueryable back to in-memory IEnumerable processing.

When does a LINQ query actually execute?

When something enumerates it: foreach, ToList, ToArray, ToDictionary, Count, First, or Any. Defining the query with Where, Select, and OrderBy does no work. Operators returning sequences stay lazy, while operators returning a single value or a concrete collection force immediate execution.

Is LINQ slower than a foreach loop?

Slightly, for the same single pass over in-memory data, because each operator allocates an enumerator and lambdas may capture state. It is not a different complexity class, and for typical request work the cost is noise. Real LINQ performance problems are usually semantic: materializing too early on IQueryable or enumerating an expensive query multiple times. Profile before rewriting.

Say what you want, mind when it runs

LINQ is at its best when the chain reads like the requirement and the execution model is a deliberate choice rather than an accident. Chain lazily while composing, materialize exactly once at the point you need stability, keep database queries translatable until the last moment. Let Any() answer existence questions. The next language stop is async and await, the piece that keeps all of this non-blocking under load, and the full series lives on the .NET series index. The rule of thumb: a query says what you want; enumeration is when you pay for it, so pay once and on purpose.

Last updated on 17 October 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *