LINQ Best Practices
Vaibhav • September 11, 2025
LINQ is one of the most expressive and powerful features in C#. It allows you to query and transform data using a consistent, readable syntax across collections, databases, XML, and more. But with great power comes great responsibility. Writing LINQ queries that are clean, efficient, and maintainable requires more than just knowing the syntax - it requires understanding how LINQ works under the hood, and how to use it wisely. In this final article of Chapter 15, we’ll walk through best practices that will help you write LINQ code that performs well, reads clearly, and scales gracefully.
Prefer Method Syntax for Simplicity
LINQ offers two syntaxes: query syntax and method syntax. While query syntax is great for readability in complex joins and groupings, method syntax is often more concise and fluent - especially for simple filters and projections.
// Query syntax
var result = from fruit in fruits
where fruit.StartsWith("a")
select fruit;
// Method syntax
var result = fruits.Where(f => f.StartsWith("a"));
The method syntax version is shorter and easier to chain with other operations. Use query syntax when it improves clarity, but default to method syntax for everyday queries.
Use Deferred Execution to Your Advantage
LINQ queries are deferred by default - they don’t execute until you enumerate them. This allows you to build queries incrementally and avoid unnecessary computation. But it also means that changes to the source collection after defining the query will affect the results.
var query = numbers.Where(n => n > 10); // Not executed yet
numbers.Add(20); // Affects query
foreach (var num in query)
{
Console.WriteLine(num); // Includes 20
}
If you want to freeze the results, materialize the query using ToList()
or
ToArray()
. This ensures consistent behavior and avoids surprises.
Materialize Queries When Needed
Materializing a query means executing it and storing the results. This is useful when:
- You need to access the results multiple times
- You want to avoid re-execution
- You’re debugging or inspecting the output
var cached = query.ToList(); // Executes once
int count = cached.Count();
int max = cached.Max();
This avoids running the query twice. It’s especially important for expensive queries or those involving external data sources.
Avoid Side Effects in Queries
LINQ is designed for pure, functional-style programming. Queries should not modify external state or rely on side effects. This ensures predictability and thread safety - especially in parallel queries.
// ❌ Avoid this
int total = 0;
var result = numbers.Select(n =>
{
total += n; // Side effect
return n;
});
Instead, use aggregation methods like Sum()
or Aggregate()
to compute totals safely.
Use Short-Circuiting Methods for Efficiency
Methods like Any()
, First()
, and Take()
short-circuit - they stop processing as soon as the result is known. This
makes them faster than methods like Count()
or ToList()
, which process the entire sequence.
// Efficient
bool hasLarge = numbers.Any(n => n > 1000);
// Less efficient
bool hasLargeAlt = numbers.Count(n => n > 1000) > 0;
Use short-circuiting methods when you only need to check for existence or retrieve a single item.
Filter Before Sorting
Sorting is expensive, especially on large collections. Always filter your data before sorting to reduce the number of items being ordered.
// Inefficient
var sorted = numbers.OrderBy(n => n).Where(n => n > 10);
// Better
var filtered = numbers.Where(n => n > 10).OrderBy(n => n);
This reduces the workload for the sorting algorithm and improves performance.
Use GroupBy()
Carefully
GroupBy()
is powerful but can be memory-intensive. Each group is stored in
memory, so avoid grouping on high-cardinality keys or when you don’t need the full group.
var grouped = products.GroupBy(p => p.Category);
If you only need counts or summaries, consider using ToLookup()
or direct
aggregation methods instead.
Use Custom Comparers for Set Operations
Set operations like Distinct()
, Union()
, and
Except()
use default equality. For custom types, provide an IEqualityComparer<T>
to define comparison logic.
class ProductComparer : IEqualityComparer<Product>
{
public bool Equals(Product x, Product y) => x.Name == y.Name;
public int GetHashCode(Product obj) => obj.Name.GetHashCode();
}
var unique = products.Distinct(new ProductComparer());
This ensures correct behavior and avoids unexpected duplicates.
Use Let
in Query Syntax for Clarity
The let
keyword allows you to introduce intermediate variables in query syntax.
This improves readability and avoids repeating expressions.
var result = from fruit in fruits
let upper = fruit.ToUpper()
where upper.StartsWith("A")
select upper;
This makes the query easier to read and maintain. Use let
to simplify complex
conditions or projections.
Use SelectMany()
to Flatten Nested Collections
SelectMany()
flattens nested sequences into a single sequence. It’s more
efficient and readable than nested loops.
List<string[]> wordGroups = new List<string[]>
{
new[] { "apple", "apricot" },
new[] { "banana", "blueberry" }
};
var allWords = wordGroups.SelectMany(group => group);
This returns a flat list of words. Use SelectMany()
when working with nested
collections.
Use DistinctBy()
in .NET 6+
.NET 6 introduced DistinctBy()
, which removes duplicates based on a property.
It’s cleaner than using a custom comparer.
var uniqueByName = products.DistinctBy(p => p.Name);
This removes duplicate products based on name. It’s a concise and expressive alternative to Distinct()
with a comparer.
Avoid Overusing ToList()
While ToList()
is useful for materializing queries, overusing it can hurt
performance and memory usage. Avoid calling ToList()
in the middle of a query
unless you need to.
// Avoid this
var temp = numbers.Where(n => n > 10).ToList().Select(n => n * 2);
Instead, keep the query deferred:
var result = numbers.Where(n => n > 10).Select(n => n * 2);
This is more efficient and avoids unnecessary memory allocation.
Use Aggregate()
for Custom Accumulation
Aggregate()
allows you to define custom accumulation logic. It’s useful for
scenarios where Sum()
or Count()
aren’t
enough.
string sentence = fruits.Aggregate((acc, next) => acc + ", " + next);
This concatenates fruit names into a sentence. Use Aggregate()
for flexible,
custom summaries.
Summary
LINQ is a powerful tool for querying and transforming data in C#. But writing great LINQ code requires more than
just knowing the syntax - it requires understanding how LINQ works and applying best practices. Use method
syntax for simplicity, leverage deferred execution wisely, and materialize queries when needed. Avoid side
effects, prefer short-circuiting methods, and filter before sorting. Use custom comparers for set operations,
let
for clarity, and SelectMany()
for
flattening. Avoid overusing ToList()
, and use Aggregate()
for custom logic. By following these practices, you’ll write LINQ
code that is clean, efficient, and maintainable - ready for real-world applications.
This concludes Chapter 15: LINQ and Query Expressions. In the next chapter, we’ll dive into advanced language features - starting with nullable reference types and pattern matching.