LINQ in C#
Query syntax, method syntax, deferred execution, GroupBy, Join, and projection with LINQ.
Query Syntax vs Method Syntax
LINQ (Language Integrated Query) lets you filter, sort, and transform collections using a consistent, readable syntax directly in C#. It works on any IEnumerable<T> — arrays, lists, dictionaries, EF Core queries, and more — so the same skills apply across in-memory data and databases. There are two syntaxes: method syntax (fluent API, the more common choice) and query syntax (SQL-like, convenient for joins and groupings). Both compile to identical IL.
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Method syntax — chain operators with lambda expressions
var evenSquares = numbers
.Where(n => n % 2 == 0) // filter even numbers
.Select(n => n * n); // project each to its square
// Query syntax — identical result, reads closer to SQL
var evenSquares2 =
from n in numbers
where n % 2 == 0
select n * n;
foreach (int sq in evenSquares)
Console.Write(sq + " "); // 4 16 36 64 100
Common LINQ Operators
LINQ provides a rich set of operators that cover virtually every data manipulation need: filtering, projecting, ordering, aggregating, and more. Knowing these well means you can express complex data transformations in a few readable lines instead of imperative loops with temporary variables. The operators compose naturally — the output of one becomes the input of the next.
record Product(string Name, string Category, decimal Price, int Stock);
var products = new List<Product>
{
new("Widget", "Tools", 9.99m, 150),
new("Gadget", "Electronics", 49.99m, 30),
new("Sprocket", "Tools", 4.99m, 200),
new("Doohickey", "Electronics", 19.99m, 80),
new("Gizmo", "Toys", 14.99m, 50),
};
// Filtering — keep only items that match a predicate
var tools = products.Where(p => p.Category == "Tools");
// Projection — shape the output into a new form
var names = products.Select(p => p.Name);
var catalog = products.Select(p => new { p.Name, p.Price }); // anonymous type
// Ordering — sort by one or more keys
var byPrice = products.OrderBy(p => p.Price);
var byPriceDesc = products.OrderByDescending(p => p.Price);
var multiSorted = products.OrderBy(p => p.Category).ThenBy(p => p.Price);
// Aggregation — reduce a collection to a single value
decimal totalValue = products.Sum(p => p.Price * p.Stock);
decimal avgPrice = products.Average(p => p.Price);
decimal maxPrice = products.Max(p => p.Price);
int count = products.Count(p => p.Category == "Tools");
// Element access — get one item or null if not found
Product? first = products.FirstOrDefault(p => p.Price < 5);
Product? single = products.SingleOrDefault(p => p.Name == "Widget");
Product cheapest = products.MinBy(p => p.Price)!;
// Existence checks — returns bool without iterating the whole list
bool hasTools = products.Any(p => p.Category == "Tools");
bool allInStock = products.All(p => p.Stock > 0);
// Set operations — Union, Intersect, Except
var toolNames = tools.Select(p => p.Name);
var expensive = products.Where(p => p.Price > 20).Select(p => p.Name);
var union = toolNames.Union(expensive);
var intersect = toolNames.Intersect(expensive);
// Take and Skip — pagination pattern
int page = 2, pageSize = 2;
var page2 = products
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize) // skip the previous pages
.Take(pageSize); // take only this page's items
GroupBy
GroupBy is the LINQ equivalent of SQL’s GROUP BY — it partitions a sequence into groups based on a key. Each group has a Key property and is itself an IEnumerable<T> of the matching items. This is invaluable for building summaries, reports, and dashboards without manually managing dictionaries.
// Group products by category — each group has Key = category name
var grouped = products.GroupBy(p => p.Category);
foreach (var group in grouped)
{
Console.WriteLine($"Category: {group.Key} ({group.Count()} items)");
foreach (var p in group.OrderBy(p => p.Price))
Console.WriteLine($" {p.Name}: ${p.Price}");
}
// GroupBy + projection — combine grouping with aggregation
var summary = products
.GroupBy(p => p.Category)
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
Total = g.Sum(p => p.Price * p.Stock), // total inventory value
Cheapest = g.Min(p => p.Price)
})
.OrderBy(s => s.Category);
foreach (var s in summary)
Console.WriteLine($"{s.Category}: {s.Count} products, total inventory ${s.Total:F0}");
Join
LINQ joins let you combine two separate collections based on a matching key, just like a SQL JOIN. This is especially useful when you have related data in separate lists or are working with in-memory objects that mirror normalized database tables. The inner join returns only matching rows; the left join (via GroupJoin/SelectMany) includes unmatched rows as well.
record Order(int Id, int CustomerId, decimal Amount);
record Customer(int Id, string Name);
var customers = new[]
{
new Customer(1, "Alice"),
new Customer(2, "Bob"),
new Customer(3, "Carol"),
};
var orders = new[]
{
new Order(101, 1, 250m),
new Order(102, 1, 80m),
new Order(103, 2, 150m),
new Order(104, 4, 50m), // CustomerId=4 has no customer record
};
// Inner join — only orders that have a matching customer
var joined = orders.Join(
customers,
order => order.CustomerId, // key from the left (orders)
customer => customer.Id, // key from the right (customers)
(order, customer) => new // result selector — shape the output
{
customer.Name,
order.Id,
order.Amount
});
foreach (var row in joined)
Console.WriteLine($"{row.Name}: Order #{row.Id} = ${row.Amount}");
// Left join — all orders, with null customer name for unmatched ones
var leftJoined = orders
.GroupJoin(
customers,
o => o.CustomerId,
c => c.Id,
(order, matchingCustomers) => new { order, matchingCustomers })
.SelectMany(
x => x.matchingCustomers.DefaultIfEmpty(), // include unmatched orders
(x, customer) => new
{
OrderId = x.order.Id,
Customer = customer?.Name ?? "(Unknown)" // null-safe fallback
});
Deferred Execution
Most LINQ operators are lazy — they don’t run until you actually iterate the results. This is called deferred execution, and it is a feature, not a bug. It means you can build up a complex query in multiple steps and only pay the execution cost once, at the point of consumption. It also means queries run against the current state of the data, not a snapshot taken when the query was defined. Understanding this prevents subtle bugs.
var data = new List<int> { 1, 2, 3, 4, 5 };
// The query is DEFINED here — no iteration happens yet
var query = data.Where(n => n > 2);
// Modify the source AFTER defining the query
data.Add(10);
// Execution happens HERE — includes 10 because it was added before iteration
foreach (int n in query)
Console.Write(n + " "); // 3 4 5 10
// Force immediate execution with ToList() to capture a snapshot
var snapshot = data.Where(n => n > 2).ToList();
data.Add(20); // does NOT affect snapshot — it was materialized above
SelectMany — Flatten Nested Collections
SelectMany projects each element to a sub-sequence and then flattens all the sub-sequences into one. It solves the common problem of “I have a list of things, each of which has a list — give me one flat list of all the inner things.” Without it, you’d need nested loops and manual concatenation.
var orders2 = new[]
{
new { Customer = "Alice", Items = new[] { "Widget", "Gadget" } },
new { Customer = "Bob", Items = new[] { "Sprocket" } },
new { Customer = "Carol", Items = new[] { "Gizmo", "Widget", "Doohickey" } },
};
// Flatten — one list of all items across all orders
var allItems = orders2.SelectMany(o => o.Items);
// Widget, Gadget, Sprocket, Gizmo, Widget, Doohickey
// Flatten with parent reference — keep the customer alongside each item
var itemsWithCustomer = orders2.SelectMany(
o => o.Items,
(order, item) => new { order.Customer, item });
LINQ to Build Pipelines
One of LINQ’s greatest strengths is composability — you can chain operators to build readable data-processing pipelines that replace what would otherwise be many lines of loops, conditions, and temporary collections. This makes the intent of the code clear and the logic easy to modify or extend.
// Real-world example: parse and summarize structured log lines
string[] logLines =
{
"2024-01-15 ERROR NullReferenceException in OrderService",
"2024-01-15 INFO Request completed in 120ms",
"2024-01-15 ERROR TimeoutException in PaymentService",
"2024-01-16 WARN High memory usage: 85%",
"2024-01-16 ERROR ArgumentException in UserService",
};
var errorSummary = logLines
.Where(line => line.Contains(" ERROR ")) // keep only errors
.Select(line => line.Split(' ', 4)) // split into parts
.Where(parts => parts.Length == 4) // guard: valid format
.Select(parts => new
{
Date = parts[0],
Message = parts[3]
})
.GroupBy(e => e.Date) // group errors by date
.Select(g => new
{
Date = g.Key,
Count = g.Count(),
Errors = g.Select(e => e.Message).ToList()
})
.OrderBy(s => s.Date);
foreach (var day in errorSummary)
{
Console.WriteLine($"{day.Date}: {day.Count} errors");
foreach (var err in day.Errors)
Console.WriteLine($" - {err}");
}
Async LINQ with EF Core
When your LINQ query targets a database through Entity Framework Core, it is translated into SQL and executed at the server — not in memory. To avoid blocking a thread while waiting for the database, EF Core provides async versions of all terminal operators. Always use these in async methods; they keep your web server threads free to handle other requests while the query runs.
// These trigger an async SQL query — call them at the end of the chain
var customers = await dbContext.Customers
.Where(c => c.IsActive)
.OrderBy(c => c.Name)
.ToListAsync(); // async materialization
// Async aggregation — no need to load all rows into memory first
int count = await dbContext.Orders
.CountAsync(o => o.Status == "Pending");
decimal total = await dbContext.Orders
.Where(o => o.CustomerId == customerId)
.SumAsync(o => o.Amount);
// Async single-item lookup
Customer? found = await dbContext.Customers
.FirstOrDefaultAsync(c => c.Email == email);