LINQ in C# -- Language Integrated Query -- is one of the most transformative features Microsoft has ever shipped for .NET developers. Since its introduction in C# 3.0, LINQ has fundamentally changed how developers query, transform, and aggregate data collections. Whether you're filtering a list of customer orders, flattening nested product categories, or computing regional sales totals, LINQ in C# gives you a consistent, composable API that reads like a description of what you want rather than a verbose, imperative description of how to get it.
This complete guide covers every major LINQ operator category, shows before/after comparisons for .NET 6, 7, and 9 additions, and gives you production-ready patterns you can apply immediately.
What Is LINQ in C#?
LINQ (Language Integrated Query) is a collection of extension methods defined on IEnumerable<T> and IQueryable<T>. The IEnumerable<T> path operates in-memory (LINQ to Objects), while IQueryable<T> builds expression trees that a provider may translate to SQL or another query model at runtime -- Entity Framework Core is the most common example. Dapper is a SQL-first micro-ORM that does not implement IQueryable<T>.
Two syntax forms are available:
namespace ECommerce.Catalog;
// Query syntax -- SQL-like keywords
var cheapItems =
from p in products
where p.Price < 50
orderby p.Name
select p.Name;
// Method syntax -- preferred for composability
var cheapItems =
products
.Where(p => p.Price < 50)
.OrderBy(p => p.Name)
.Select(p => p.Name);
Both syntaxes compile to the same IL. Method syntax is generally preferred in modern C# codebases because it is more composable, supports all LINQ operators (some operators have no query syntax equivalent), and plays better with IDE code completion.
LINQ fits naturally into architectures that emphasize clear separation of concerns. When you pair it with CQRS with Feature Slices, each query handler uses LINQ to shape its own data set -- with no shared, tangled repository layer pulling things in every direction.
Filtering: Where, Any, All, Contains, OfType
Filtering is the most common LINQ operation. Where accepts a predicate and returns every element satisfying it. Any and All answer existence and universality questions and short-circuit as soon as a result is known.
namespace ECommerce.Orders;
public sealed record Order(int Id, string CustomerId, decimal Total, bool IsShipped);
// Where -- basic and compound predicates
IEnumerable<Order> largeUnshipped =
orders.Where(o => !o.IsShipped && o.Total > 500m);
// Any -- stops at the first match
bool hasPending = orders.Any(o => !o.IsShipped);
// All -- stops at the first failure; returns true for empty collections
bool allShipped = orders.Any() && orders.All(o => o.IsShipped);
// OfType -- filter a heterogeneous collection by runtime type
IEnumerable<PremiumOrder> premiumOnly =
mixedOrders.OfType<PremiumOrder>();
OfType<T>() is particularly powerful in Plugin Architecture in C#, where you load a collection of IPlugin objects and use OfType<IAnalyticsPlugin>() to retrieve only the subset that implement a specific capability interface.
Always prefer Any() over Count() > 0 -- Any() short-circuits at the first element; Count() enumerates the entire collection.
Projection: Select and SelectMany
Select transforms each element in a sequence. SelectMany flattens nested sequences into a single stream.
namespace ECommerce.Catalog;
public sealed record Category(string Name, IReadOnlyList<Product> Products);
public sealed record Product(string Sku, string Name, decimal Price);
// Select -- project to a new type
IEnumerable<string> skus = products.Select(p => p.Sku);
// Select with index (.NET 6+)
IEnumerable<(int Index, string Sku)> indexed =
products.Select((p, i) => (i, p.Sku));
// .NET 9 -- Index() replaces the Select((item, i) => ...) pattern
IEnumerable<(int Index, Product Item)> withIndex =
products.Index();
// SelectMany -- flatten all products across categories
IEnumerable<Product> allProducts =
categories.SelectMany(c => c.Products);
// SelectMany with result selector -- keep parent context
IEnumerable<(string CategoryName, string Sku)> flat =
categories.SelectMany(
c => c.Products,
(c, p) => (c.Name, p.Sku));
When projecting to data-transfer objects for a read side, Select is the natural operator. In Feature Slicing in C#, each feature slice defines its own projection -- no shared mapper leaking domain internals across the entire codebase.
Ordering: OrderBy, ThenBy, Order (.NET 7)
LINQ ordering is stable -- elements that compare equal retain their original relative order from the input sequence.
namespace ECommerce.Inventory;
public sealed record Product(string Sku, string Name, decimal Price, int Stock);
// Multi-key sort
IOrderedEnumerable<Product> sorted =
products
.OrderBy(p => p.Price)
.ThenByDescending(p => p.Stock);
// Before .NET 7 -- trivial single-key sort on naturally comparable types
IEnumerable<decimal> sortedPrices = prices.OrderBy(x => x);
// .NET 7 -- Order() and OrderDescending() remove the redundant key selector
IEnumerable<decimal> sortedPricesNew = prices.Order();
IEnumerable<string> sortedNamesDesc = names.OrderDescending();
The .NET 7 Order() and OrderDescending() methods work on any type implementing IComparable<T>. For complex types, you still use OrderBy(keySelector) with a lambda or custom IComparer<T>.
Grouping: GroupBy, ToLookup, CountBy (.NET 9), AggregateBy (.NET 9)
namespace ECommerce.Reporting;
public sealed record Sale(string Region, string ProductSku, decimal Amount);
// GroupBy -- deferred, buffering groups (buffers internal group state on enumeration)
IEnumerable<IGrouping<string, Sale>> byRegion =
sales.GroupBy(s => s.Region);
foreach (var group in byRegion)
{
Console.WriteLine($"{group.Key}: {group.Sum(s => s.Amount):C}");
}
// ToLookup -- eager, supports random-access by key
ILookup<string, Sale> lookup = sales.ToLookup(s => s.Region);
IEnumerable<Sale> westSales = lookup["West"];
// .NET 9 -- CountBy: count per key without materializing groups
IEnumerable<KeyValuePair<string, int>> regionCounts =
sales.CountBy(s => s.Region);
// .NET 9 -- AggregateBy: accumulate per key without GroupBy + aggregation
IEnumerable<KeyValuePair<string, decimal>> regionTotals =
sales.AggregateBy(
keySelector: s => s.Region,
seedSelector: _ => 0m,
func: (acc, s) => acc + s.Amount);
CountBy and AggregateBy avoid materializing grouped sub-sequences into memory, which is a significant win on large datasets where you only need summary values, not the individual group elements.
Joins: Join, GroupJoin, LeftJoin (.NET 10), RightJoin (.NET 10), Zip
namespace ECommerce.Reports;
public sealed record Customer(int Id, string Name);
public sealed record Order(int Id, int CustomerId, decimal Total);
// Inner join
var customerOrders =
customers.Join(
orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { c.Name, o.Total });
// GroupJoin -- left join with grouping; all customers included
var customersWithTotals =
customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, orderGroup) => new
{
c.Name,
Total = orderGroup.Sum(o => o.Total)
});
// Before .NET 10 -- left join required GroupJoin + SelectMany + DefaultIfEmpty
var leftJoinOld =
customers
.GroupJoin(orders, c => c.Id, o => o.CustomerId,
(c, og) => new { c, og })
.SelectMany(
x => x.og.DefaultIfEmpty(),
(x, o) => new { x.c.Name, OrderTotal = o?.Total ?? 0 });
// .NET 10 -- LeftJoin is a first-class operator
var leftJoined =
customers.LeftJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { c.Name, OrderTotal = o?.Total ?? 0 });
// .NET 10 -- RightJoin
var rightJoined =
orders.RightJoin(
customers,
o => o.CustomerId,
c => c.Id,
(o, c) => new { c.Name, OrderTotal = o?.Total ?? 0 });
The LeftJoin and RightJoin operators introduced in .NET 10 are direct replacements for the verbose GroupJoin + SelectMany + DefaultIfEmpty pattern that developers had been writing by hand for over a decade.
Set Operations: Distinct, DistinctBy, Union, Intersect, Except
namespace ECommerce.Catalog;
// Distinct -- uses default equality
IEnumerable<string> uniqueSkus = skus.Distinct();
// Before .NET 6 -- DistinctBy required MoreLINQ or manual GroupBy
IEnumerable<Product> uniqueByNameOld =
products
.GroupBy(p => p.Name)
.Select(g => g.First());
// .NET 6 -- DistinctBy: deduplicate by projected key
IEnumerable<Product> uniqueByName =
products.DistinctBy(p => p.Name);
// Union, Intersect, Except -- set operations on sequences
IEnumerable<string> allSkus = catalogSkus.Union(warehouseSkus);
IEnumerable<string> inBoth = catalogSkus.Intersect(warehouseSkus);
IEnumerable<string> orphans = warehouseSkus.Except(catalogSkus);
// .NET 6 -- ExceptBy, IntersectBy, UnionBy: set ops by key
IEnumerable<Product> newArrivals =
incomingProducts.ExceptBy(
existingProducts.Select(p => p.Sku),
p => p.Sku);
Aggregation: Count, Sum, Min, Max, Average, Aggregate, MinBy/MaxBy
namespace ECommerce.Analytics;
// Basic aggregation
int orderCount = orders.Count();
decimal totalRevenue = orders.Sum(o => o.Total);
decimal avgOrderValue = orders.Average(o => o.Total);
// Before .NET 6 -- MinBy/MaxBy required manual Aggregate or MoreLINQ
Order mostExpensiveOld = orders.Aggregate((a, b) => a.Total > b.Total ? a : b);
// .NET 6 -- MinBy and MaxBy: return the element, not just the key value
Order mostExpensive = orders.MaxBy(o => o.Total)!;
Order cheapest = orders.MinBy(o => o.Total)!;
// Aggregate -- general-purpose fold/reduce
string skuList = products
.Select(p => p.Sku)
.Aggregate((a, b) => $"{a}, {b}");
// .NET 6 -- TryGetNonEnumeratedCount: fast count without iteration
if (orders.TryGetNonEnumeratedCount(out int count))
{
Console.WriteLine($"Fast count: {count} orders (no enumeration)");
}
MinBy and MaxBy are especially useful when you need the entire object associated with the minimum or maximum value -- not just the value itself.
Element Access: First, Last, Single, ElementAt, Chunk
namespace ECommerce.Paging;
// First/Last -- throw if empty; use *OrDefault variants for safe access
Order latestOrder =
orders.OrderByDescending(o => o.PlacedAt).First();
Order? maybeFirst =
orders.FirstOrDefault(o => o.CustomerId == "C001");
// Single -- exactly one element expected (throws if 0 or 2+)
Order exactOrder = orders.Single(o => o.Id == 42);
// ElementAt -- random access by index
Order thirdOrder = orders.ElementAt(2);
// Before .NET 6 -- batching required manual Skip/Take loops
var batches = new List<Order[]>();
for (int i = 0; i < orders.Count; i += 100)
{
batches.Add(orders.Skip(i).Take(100).ToArray());
}
// .NET 6 -- Chunk: split a sequence into fixed-size batches
foreach (Order[] batch in orders.Chunk(100))
{
await ProcessBatchAsync(batch);
}
Chunk is one of the most immediately useful .NET 6 additions for production code -- batch email sends, bulk database inserts, API rate-limited calls, and parallel processing all benefit from it.
Deferred Execution and When to Materialize
Most LINQ operators on IEnumerable<T> are lazy -- the query is not evaluated when you define it. Evaluation happens when you iterate over it.
namespace ECommerce.Catalog;
// This line does zero work
IEnumerable<Product> query = products.Where(p => p.Price > 100);
// The filter runs here -- on every iteration
foreach (var p in query) { Console.WriteLine(p.Name); }
// If you iterate again, the filter runs again
int count = query.Count(); // second full pass through the source
// Materialize once when you need a stable snapshot or multiple iterations
List<Product> snapshot = products.Where(p => p.Price > 100).ToList();
// Common gotcha: the source changes between definition and iteration
var list = new List<Product> { /* existing items */ };
var liveQuery = list.Where(p => p.Price > 100);
list.Add(new Product("X", "Late Addition", 200m, 0)); // this item IS included
Call ToList() or ToArray() when:
- You need a stable snapshot (the source might change)
- You're iterating the result more than once
- You're closing over a database context or connection that will be disposed before iteration
This is especially important with CQRS with Feature Slices -- query handlers should return materialized results, not lazy IEnumerable<T>, to avoid lifetime bugs with DbContext.
LINQ in Architecture
LINQ integrates naturally with most architectural patterns. In Feature Slicing in C#, each feature slice owns its own queries -- no massive shared repository with 40 overloads bleeding concerns across the entire application. With Plugin Architecture in C#, OfType<T>() and Where activate only the plugins that satisfy a specific capability contract.
When you model domain state with C# enums, LINQ's Where, GroupBy, and CountBy operators pair naturally -- orders.GroupBy(o => o.Status) is clean, readable, and immediately obvious to anyone who reads the code six months later.
If you're building intelligence into your queries -- for example, finding semantically similar documents across a corpus -- you can extend LINQ-based pipelines with AI tooling. See Build a Semantic Search Engine with Semantic Kernel in C# for a concrete example.
New in .NET 6-9: Summary Table
| Method | Version | Purpose |
|---|---|---|
DistinctBy(keySelector) |
.NET 6 | Deduplicate by projected key |
MinBy(keySelector) |
.NET 6 | Element with minimum projected key |
MaxBy(keySelector) |
.NET 6 | Element with maximum projected key |
ExceptBy / IntersectBy / UnionBy |
.NET 6 | Set operations by key selector |
Chunk(size) |
.NET 6 | Split sequence into fixed-size batches |
TryGetNonEnumeratedCount |
.NET 6 | Fast count without enumeration |
Order() |
.NET 7 | Sort naturally comparable types (no key selector) |
OrderDescending() |
.NET 7 | Descending sort without key selector |
CountBy(keySelector) |
.NET 9 | Count per key without materializing groups |
AggregateBy(key, seed, func) |
.NET 9 | Accumulate per key without GroupBy |
Index() |
.NET 9 | Enumerate with index (replaces Select((x, i) => ...)) |
LeftJoin(...) |
.NET 10 | Left outer join without GroupJoin boilerplate |
RightJoin(...) |
.NET 10 | Right outer join without GroupJoin boilerplate |
Frequently Asked Questions
What is LINQ in C# and why should I use it?
LINQ in C# is a set of extension methods (and an optional query syntax) for querying and transforming any IEnumerable<T> or IQueryable<T> sequence. You should use it because it produces declarative, readable code that clearly expresses intent -- instead of nested loops with index variables, you express filtering, sorting, and aggregation as composable method chains. It also eliminates entire categories of off-by-one errors and makes code far easier to review and refactor.
What is the difference between query syntax and method syntax in LINQ?
Query syntax uses SQL-like keywords (from, where, select, orderby) and compiles to method syntax calls under the hood. Method syntax chains extension method calls directly on the collection. Both are functionally equivalent, but method syntax is preferred in most modern C# codebases because it supports all LINQ operators (not all have query syntax equivalents), plays better with IDE tooling, and is more naturally composable.
When should I call ToList() or ToArray() in LINQ?
Materialize with ToList() or ToArray() when you need a stable snapshot (the source collection might change before you're done iterating), when you'll iterate the result more than once (to avoid double-enumeration cost), or when the query captures a scoped resource like a database context that could be disposed before iteration. Leave the query as IEnumerable<T> when streaming through a pipeline and you only need a single forward pass.
What is the performance difference between GroupBy and CountBy in .NET 9?
GroupBy allocates an IGrouping<K, T> object for each group and buffers all matching elements in memory for that group. CountBy (new in .NET 9) only tracks the count per key -- it never materializes the group elements. For large collections where you only need a frequency map, CountBy is significantly more memory-efficient than GroupBy(...).Select(g => (g.Key, g.Count())).
What is deferred execution in LINQ?
Deferred execution means the query is not evaluated when you define it -- it is evaluated when you iterate over it. Most LINQ operators (Where, Select, OrderBy, etc.) are deferred. Operators like ToList(), Count(), First(), and Sum() are eager and force immediate evaluation. The practical implication is that defining a LINQ query is cheap; evaluating it repeatedly on a slow source (database, file, HTTP) can be expensive.
Does LINQ work with databases?
Yes. When your source implements IQueryable<T> -- as provided by Entity Framework Core -- the LINQ expression tree is translated to SQL at execution time. The same method syntax you use for in-memory collections maps to database queries, though some operators are untranslatable and will throw at runtime or force client-side evaluation. Always inspect generated SQL in development to verify the translation is what you expect.
Is LINQ thread-safe?
LINQ to Objects operations are safe to run on independent sequences concurrently. However, LINQ does not make the underlying collection thread-safe. If you modify a List<T> on one thread while a LINQ query iterates it on another, you will get undefined behavior. Use ConcurrentBag<T>, ImmutableArray<T>, or explicit locking when sharing mutable collections across threads -- then LINQ over them freely.
Conclusion
LINQ in C# is indispensable in every .NET developer's toolkit. This guide has covered every major operator category -- filtering, projection, ordering, grouping, joins, set operations, aggregation, and element access -- plus all the additions from .NET 6 through .NET 10. The newer operators (CountBy, AggregateBy, LeftJoin, Index, Order) eliminate boilerplate patterns that developers had been writing by hand for years. Apply them deliberately and your query code will become more readable, more efficient, and far easier to maintain.

