
Master LINQ in C# with 10 practical, runnable examples covering Where, Select, GroupBy, Join, and more. Learn best practices and common pitfalls today.
If you write C# for a living, you use LINQ every single day — whether you realize it or not. This C# LINQ tutorial walks through 10 practical, runnable examples that cover the operations developers actually use in production: filtering, projecting, grouping, joining, aggregating, and paging data. Along the way, we explain why each operator behaves the way it does, so you can avoid the pitfalls (deferred execution, multiple enumeration, N+1 queries) that trip up even experienced .NET developers.
All examples target .NET 8 or later and run as-is in a console app. Let's start with the sample data used throughout.
Sample Data for This C# LINQ Tutorial
We'll use a small product catalog with orders. Copy this into a Program.cs to follow along.
using System;
using System.Collections.Generic;
using System.Linq;
public record Product(int Id, string Name, string Category, decimal Price, int Stock);
public record Order(int Id, int ProductId, int Quantity, DateTime OrderedAt);
public static class SampleData
{
public static List<Product> Products = new()
{
new(1, "Laptop", "Electronics", 1299.99m, 12),
new(2, "Mouse", "Electronics", 24.50m, 150),
new(3, "Desk Chair", "Furniture", 189.00m, 8),
new(4, "Standing Desk", "Furniture", 449.00m, 0),
new(5, "Notebook", "Stationery", 3.99m, 500),
new(6, "Pen Set", "Stationery", 12.00m, 75),
new(7, "Monitor", "Electronics", 329.00m, 20),
};
public static List<Order> Orders = new()
{
new(101, 1, 2, new DateTime(2026, 8, 1)),
new(102, 2, 10, new DateTime(2026, 8, 3)),
new(103, 5, 100, new DateTime(2026, 8, 5)),
new(104, 7, 3, new DateTime(2026, 8, 10)),
new(105, 1, 1, new DateTime(2026, 8, 15)),
new(106, 6, 5, new DateTime(2026, 8, 20)),
};
}
1. Filtering with Where
Where is the most-used operator in LINQ in C#. It takes a predicate and returns only the elements that match. Note that it returns an IEnumerable<T>, not a list — nothing executes until you enumerate it.
var inStockElectronics = SampleData.Products
.Where(p => p.Category == "Electronics" && p.Stock > 0);
foreach (var p in inStockElectronics)
Console.WriteLine($"{p.Name} - ${p.Price}");
// Laptop - $1299.99, Mouse - $24.50, Monitor - $329.00
Why it matters: Because Where is lazy, you can build queries conditionally without paying for intermediate collections:
IEnumerable<Product> query = SampleData.Products;
string? categoryFilter = "Furniture";
decimal? maxPrice = 200m;
if (categoryFilter is not null) query = query.Where(p => p.Category == categoryFilter);
if (maxPrice is not null) query = query.Where(p => p.Price <= maxPrice);
var results = query.ToList(); // executes once, with both filters applied
2. Projecting with Select
Select transforms each element into a new shape. Use it to return only the data your caller needs — a DTO, an anonymous type, or a single property.
var summaries = SampleData.Products
.Select(p => new { p.Name, DisplayPrice = p.Price.ToString("C") });
foreach (var s in summaries)
Console.WriteLine($"{s.Name}: {s.DisplayPrice}");
// Select with index
var numbered = SampleData.Products
.Select((p, i) => $"{i + 1}. {p.Name}");
When the query runs against Entity Framework Core, Select is translated to SQL, so projecting early means fewer columns pulled from the database. That's one of the easiest performance wins in any C# LINQ query.
3. Sorting with OrderBy and ThenBy
A classic interview question: what's the difference between OrderBy(...).OrderBy(...) and OrderBy(...).ThenBy(...)? The first replaces the sort; the second adds a secondary key.
var sorted = SampleData.Products
.OrderBy(p => p.Category)
.ThenByDescending(p => p.Price);
foreach (var p in sorted)
Console.WriteLine($"{p.Category,-12} {p.Name,-14} {p.Price,8:C}");
LINQ's OrderBy is a stable sort — equal elements keep their original order. List<T>.Sort() is not stable, which is a subtle source of bugs when you switch between them.
4. Grouping with GroupBy
GroupBy is where LINQ starts to feel like SQL. Each group is an IGrouping<TKey, TElement> — it has a Key and is itself enumerable.
var byCategory = SampleData.Products
.GroupBy(p => p.Category)
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
TotalStock = g.Sum(p => p.Stock),
AvgPrice = g.Average(p => p.Price)
});
foreach (var g in byCategory)
Console.WriteLine($"{g.Category}: {g.Count} products, {g.TotalStock} in stock, avg {g.AvgPrice:C}");
Output:
Electronics: 3 products, 182 in stock, avg $551.16
Furniture: 2 products, 8 in stock, avg $319.00
Stationery: 2 products, 575 in stock, avg $7.99
5. Joining Collections with Join
LINQ Join in C# works like an SQL inner join: it matches elements from two sequences on a key and lets you project the combined result.
var orderDetails = SampleData.Orders
.Join(SampleData.Products,
order => order.ProductId, // outer key
product => product.Id, // inner key
(order, product) => new
{
order.Id,
product.Name,
order.Quantity,
LineTotal = order.Quantity * product.Price
});
foreach (var od in orderDetails)
Console.WriteLine($"Order {od.Id}: {od.Quantity} x {od.Name} = {od.LineTotal:C}");
The same thing in query syntax reads closer to SQL, which many teams prefer for multi-join queries:
var details = from o in SampleData.Orders
join p in SampleData.Products on o.ProductId equals p.Id
select new { o.Id, p.Name, LineTotal = o.Quantity * p.Price };
Left join with GroupJoin
Need products that have no orders too? Use GroupJoin plus DefaultIfEmpty:
var productsWithOrders = SampleData.Products
.GroupJoin(SampleData.Orders,
p => p.Id,
o => o.ProductId,
(p, orders) => new { p.Name, OrderCount = orders.Count() });
foreach (var x in productsWithOrders)
Console.WriteLine($"{x.Name}: {x.OrderCount} orders");
// Desk Chair: 0 orders, Standing Desk: 0 orders ...
6. Aggregating with Sum, Average, Min, Max, and Count
Aggregates execute immediately and return a single value. Combine them with Where for targeted metrics.
decimal inventoryValue = SampleData.Products.Sum(p => p.Price * p.Stock);
decimal cheapest = SampleData.Products.Min(p => p.Price);
Product priciest = SampleData.Products.MaxBy(p => p.Price)!; // .NET 6+
int outOfStock = SampleData.Products.Count(p => p.Stock == 0);
Console.WriteLine($"Inventory value: {inventoryValue:C}");
Console.WriteLine($"Most expensive: {priciest.Name}");
Console.WriteLine($"Out of stock: {outOfStock}");
Pitfall: Average throws InvalidOperationException on an empty sequence. Guard with DefaultIfEmpty(0) or check Any() first.
7. Finding Single Elements: First, Single, and the OrDefault Variants
Picking the right one communicates intent and catches bugs early:
First()— at least one match expected; throws if none.FirstOrDefault()— zero or more matches; returnsnull/default if none.Single()— exactly one match expected; throws if zero or more than one.SingleOrDefault()— zero or one match; throws if more than one.
var laptop = SampleData.Products.Single(p => p.Name == "Laptop");
var missing = SampleData.Products.FirstOrDefault(p => p.Name == "Tablet"); // null
// .NET 6+ lets you supply a fallback:
var fallback = SampleData.Products.FirstOrDefault(p => p.Name == "Tablet",
new Product(0, "N/A", "", 0, 0));
Use Single when looking up by a unique key. If your "unique" key ever returns two rows, you want an exception, not silently wrong data.
8. Paging with Skip and Take
Every API list endpoint needs paging. Skip and Take make it trivial, and EF Core translates them to OFFSET/FETCH in SQL.
int pageSize = 3;
int totalPages = (int)Math.Ceiling(SampleData.Products.Count / (double)pageSize);
for (int page = 1; page <= totalPages; page++)
{
var items = SampleData.Products
.OrderBy(p => p.Id) // ALWAYS order before paging
.Skip((page - 1) * pageSize)
.Take(pageSize);
Console.WriteLine($"Page {page}: {string.Join(", ", items.Select(p => p.Name))}");
}
Why the OrderBy? Without a deterministic order, a database is free to return rows in any sequence, so page 2 may repeat items from page 1. Also check out Chunk(size) (.NET 6+) for splitting a sequence into fixed-size arrays in memory.
9. Set Operations: Distinct, Union, Intersect, Except
These are underused but incredibly handy for comparing lists — for example, syncing data between two systems.
var categories = SampleData.Products.Select(p => p.Category).Distinct();
// Electronics, Furniture, Stationery
var orderedProductIds = SampleData.Orders.Select(o => o.ProductId).Distinct();
var allProductIds = SampleData.Products.Select(p => p.Id);
var neverOrdered = allProductIds.Except(orderedProductIds); // 3, 4
var both = allProductIds.Intersect(orderedProductIds); // 1, 2, 5, 6, 7
// .NET 6+: DistinctBy for objects
var onePerCategory = SampleData.Products.DistinctBy(p => p.Category);
For custom classes, set operations rely on Equals/GetHashCode. Records give you value equality for free, which is one reason they pair so nicely with LINQ.
10. Flattening with SelectMany
SelectMany turns a sequence of sequences into one flat sequence. It's the operator that feels confusing until it clicks — then you use it everywhere.
var customers = new[]
{
new { Name = "Alice", Tags = new[] { "vip", "newsletter" } },
new { Name = "Bob", Tags = new[] { "newsletter" } },
new { Name = "Cara", Tags = Array.Empty<string>() },
};
// All tags, flattened and deduplicated
var allTags = customers.SelectMany(c => c.Tags).Distinct();
// vip, newsletter
// Keep the parent alongside each child
var pairs = customers.SelectMany(c => c.Tags, (c, tag) => $"{c.Name} -> {tag}");
// Alice -> vip, Alice -> newsletter, Bob -> newsletter
Notice Cara disappears from the output entirely — an empty inner sequence contributes nothing. If that's not what you want, use DefaultIfEmpty() on the inner sequence.
C# LINQ Best Practices and Common Pitfalls
Understand deferred execution
Most LINQ operators (Where, Select, OrderBy, GroupBy, Join) are lazy. The query runs each time you enumerate it. This code hits the source twice:
var expensive = SampleData.Products.Where(p => p.Price > 100);
Console.WriteLine(expensive.Count()); // enumerates once
foreach (var p in expensive) { } // enumerates again
For in-memory lists that's cheap; against a database it's two round trips. Materialize with ToList() or ToArray() when you'll iterate more than once. Modern analyzers (CA1851) flag "possible multiple enumeration" for exactly this reason.
Prefer specific overloads over chains
Count(predicate) beats Where(predicate).Count(); Any(predicate) beats Where(predicate).Any(); and Any() beats Count() > 0, because Any stops at the first match while Count walks the whole sequence.
Know when LINQ is translated vs. executed in memory
With EF Core, everything before a ToList()/AsEnumerable() becomes SQL. Calling a C# method the provider can't translate inside Where throws at runtime. Put the filter first, pull to memory, then apply custom logic.
Don't over-LINQ hot paths
LINQ allocates iterators and delegates. In tight loops running millions of times — game loops, parsers, high-frequency services — a plain for loop can be several times faster. Measure with BenchmarkDotNet before rewriting, but be aware of the trade-off.
Method syntax vs. query syntax
Both compile to the same calls. Method syntax (.Where().Select()) supports every operator; query syntax (from ... select) is more readable for joins and let clauses. Pick one style per codebase and stay consistent.
Conclusion: Key Takeaways from This C# LINQ Tutorial
LINQ in C# is the fastest path from "I have a collection" to "I have the answer." The 10 examples in this C# LINQ tutorial cover more than 90% of what you'll write day to day:
- Where / Select — filter and shape data; both are lazy and compose freely.
- OrderBy / ThenBy — stable, multi-key sorting; always order before paging.
- GroupBy / Join / GroupJoin — SQL-style grouping, inner joins, and left joins.
- Sum / Average / MaxBy / Count — immediate aggregates; guard empty sequences.
- First / Single + OrDefault — express your expectations and fail fast.
- Skip / Take / Chunk — paging done right.
- Distinct / Except / Intersect / DistinctBy — set logic for comparing lists.
- SelectMany — flatten nested collections.
- Deferred execution — materialize once when you enumerate more than once.
Master these, internalize the deferred-execution model, and you'll write C# that's shorter, clearer, and easier to review. Next, try rewriting a nested foreach in your own project using GroupBy or SelectMany — it's the best way to make these operators second nature.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment