Back to Blog
C#

C# LINQ Usage: Practical Patterns and Performance

Practical LINQ usage in C#: query vs method syntax, deferred execution, common operations, and performance tradeoffs for real applications.

LINQC#.NETQuery SyntaxDeferred Execution
A visual metaphor of querying a collection with LINQ, showing a pipeline of data transformation steps.

LINQ (Language Integrated Query) is a set of language and runtime features that let you query collections in C# using a declarative style. The most common usage involves filtering, transforming, and grouping data from arrays, lists, or database queries. This article focuses on practical C# LINQ patterns, covering syntax choices, execution behavior, and performance tradeoffs that matter in real applications.

Query Syntax vs Method Syntax

LINQ supports two syntaxes: query syntax (similar to SQL) and method syntax (fluent calls). Query syntax is often more readable for complex queries involving multiple operators like join or group.

// Query syntax var adults = from person in people where person.Age >= 18 select person.Name; // Method syntax var adultsMethod = people .Where(person => person.Age >= 18) .Select(person => person.Name);

Both produce identical results. Query syntax is compiled into method calls at compile time, so there is no runtime difference. Choose based on readability. For simple filtering and projection, method syntax is usually more concise. For multi-step queries, query syntax can be clearer because it separates from, where, and select clauses.

Deferred Execution and Immediate Execution

Most LINQ methods that return IEnumerable<T> use deferred execution. The query is not evaluated until you iterate over it. This has important implications for performance and correctness.

var query = numbers.Where(n => n > 5); // No execution yet var count = query.Count(); // Executes the query

Deferred execution means that if the source collection changes before you enumerate, the query reflects the new data. This is useful for building dynamic queries, but it can cause surprising behavior if you store a query and expect a snapshot. Methods like ToList(), ToArray(), Count(), and First() force immediate execution.

var snapshot = numbers.Where(n => n > 5).ToList(); // Executes now

Use deferred execution when you want to compose queries without extra memory. Use immediate execution when you need a stable result or when the source may change.

Common LINQ Operations: Where, Select, OrderBy, GroupBy, Join

These five operations cover the majority of real-world LINQ usage. Where filters, Select projects, OrderBy sorts, GroupBy partitions, and Join combines two sequences.

var filtered = products.Where(p => p.Price > 100); var names = products.Select(p => p.Name); var sorted = products.OrderByDescending(p => p.Price); var byCategory = products.GroupBy(p => p.Category); var joined = products.Join(categories, p => p.CategoryId, c => c.Id, (p, c) => new { p.Name, c.CategoryName });

GroupBy returns a sequence of groups, each with a Key property and an IEnumerable<T> of elements. Join is often more readable with query syntax, but method syntax works fine. When joining, ensure the key types match exactly; otherwise, the query will not compile.

Handling Nulls and Empty Sequences

LINQ methods throw ArgumentNullException if the source is null. Empty sequences are handled gracefully by most methods, but some like First() and Single() throw InvalidOperationException when the sequence is empty. Use FirstOrDefault() or SingleOrDefault() to return null (or default value) instead.

var firstProduct = products.FirstOrDefault(); // null if empty var singleProduct = products.SingleOrDefault(p => p.Id == 42); // null if not found

Be careful with DefaultIfEmpty() when you need a default value in a join or aggregate. For example, products.Select(p => p.Price).DefaultIfEmpty(0).Average() returns 0 for an empty list instead of throwing.

Performance Considerations: When to Use LINQ vs Traditional Loops

LINQ adds a small overhead compared to hand-written loops because it introduces delegate calls and iterator state machines. In most business applications, this overhead is negligible. However, in hot paths that process millions of records, a for loop can be measurably faster.

// Traditional loop int sum = 0; foreach (var n in numbers) sum += n; // LINQ int sumLinq = numbers.Sum();

Sum() runs as a built-in loop, but for complex transformations, a loop may avoid intermediate allocations. For example, chaining multiple Select calls creates new iterators at each step. If memory allocation is a concern, consider using a single loop or using List<T> with a pre-allocated capacity.

Another performance factor is the difference between IEnumerable<T> and IQueryable<T>. With database providers, IQueryable builds an expression tree that can be translated to SQL, pushing filtering to the server. If you work with an IEnumerable instead, LINQ to Objects operators run in memory, so the database query is materialized before additional filtering is applied. Keep the query as IQueryable for as long as you still want the provider to translate operations to the server.

LINQ and IQueryable: Working with Databases

When using LINQ to Entities, methods like Where and Select are not executed in memory. Instead, they add nodes to an expression tree (a Where predicate, for example, is represented as an Expression<Func<T, bool>>) that the ORM translates into SQL. This is why you cannot use arbitrary C# methods inside a LINQ to Entities query—only those that the provider can translate.

using (var context = new MyDbContext()) { var query = context.Products .Where(p => p.Price > 100) .OrderBy(p => p.Name); // SQL is generated when you enumerate or call ToList() }

Be aware that calling ToList() on an IQueryable executes the SQL and loads the results into memory. If you need to further filter the in-memory data, you can do so, but it defeats the purpose of server-side filtering. For the greatest benefit, keep the query as IQueryable for as long as possible before materializing it.

Common Pitfalls and How to Avoid Them

One common mistake is capturing a foreach loop variable in a LINQ query. In C# 5 and later, the foreach iteration variable is scoped per iteration, so closures capture the value for that iteration. In earlier versions of C#, the same variable was reused across iterations, which could produce unexpected results.

Another pitfall is using Count() on an IEnumerable that is actually a database query. This forces the entire query to execute and then counts in memory. Instead, use Any() to check for existence, which is optimized for short-circuiting.

// Inefficient if (products.Where(p => p.Price > 100).Count() > 0) // Better if (products.Any(p => p.Price > 100))

Finally, remember that LINQ methods are not always side-effect free. If you use Select with a method that has side effects, the deferred execution means the side effects happen at enumeration time, not when the query is defined. This can lead to surprising behavior if the source changes between definition and enumeration. Prefer pure functions in LINQ queries to keep behavior predictable.

C# LINQ Usage: Practical Patterns and Code Examples | RYUSLOG DEV