C# LINQ: Elegant Data Queries Built Into the Language
Master LINQ from query syntax to method chains: filtering, projection, grouping, joining, aggregation, deferred execution, and real product catalog examples.
C# LINQ: Elegant Data Queries Built Into the Language
LINQ (Language Integrated Query) is one of C#'s most powerful features. It lets you query and transform collections using a uniform syntax — whether the data comes from a list in memory, a database (via Entity Framework), XML, or a remote service. This article covers LINQ thoroughly, using a product catalog as the running example.
Query Syntax vs Method Syntax
LINQ has two flavours — both compile to the same thing:
var products = new List<Product>
{
new("Laptop", 999.99m, "Electronics", 15, true),
new("Mouse", 29.99m, "Electronics", 50, true),
new("Desk", 349.99m, "Furniture", 8, true),
new("Pen", 1.99m, "Stationery", 200, true),
new("Monitor", 449.99m, "Electronics", 3, false),
new("Chair", 299.99m, "Furniture", 0, true),
new("Notebook", 4.99m, "Stationery", 100, true),
new("Headphones", 89.99m, "Electronics", 22, true),
};
// === Query syntax (SQL-like) ===
var expensiveQuery =
from p in products
where p.Price > 100m && p.IsActive
orderby p.Price descending
select new { p.Name, p.Price, p.Category };
// === Method syntax (fluent/lambda) ===
var expensiveFluent = products
.Where(p => p.Price > 100m && p.IsActive)
.OrderByDescending(p => p.Price)
.Select(p => new { p.Name, p.Price, p.Category });
// Both produce identical results
foreach (var item in expensiveQuery)
Console.WriteLine($"{item.Name,-15} {item.Price,10:C} {item.Category}");Which should you use? Method syntax is more common in modern C# code and supports more operators. Query syntax can be more readable for complex joins and multi-level queries. Know both; use what's clearest.
Data Setup
// Models used throughout this article
public record Product(
string Name,
decimal Price,
string Category,
int Stock,
bool IsActive
);
public record Order(
int Id,
string CustomerName,
string ProductName,
int Quantity,
DateTime OrderDate,
string Status // "Pending", "Shipped", "Delivered", "Cancelled"
);
// Sample data
static List<Product> GetProducts() => new()
{
new("Laptop", 999.99m, "Electronics", 15, true),
new("Mouse", 29.99m, "Electronics", 50, true),
new("Desk", 349.99m, "Furniture", 8, true),
new("Pen", 1.99m, "Stationery", 200, true),
new("Monitor", 449.99m, "Electronics", 3, false),
new("Chair", 299.99m, "Furniture", 0, true),
new("Notebook", 4.99m, "Stationery", 100, true),
new("Headphones", 89.99m, "Electronics", 22, true),
new("Keyboard", 79.99m, "Electronics", 5, true),
new("Webcam", 59.99m, "Electronics", 0, true),
};
static List<Order> GetOrders() => new()
{
new(1, "Alice", "Laptop", 1, new DateTime(2026,1,5), "Delivered"),
new(2, "Bob", "Mouse", 2, new DateTime(2026,1,8), "Delivered"),
new(3, "Alice", "Monitor", 1, new DateTime(2026,2,1), "Shipped"),
new(4, "Carol", "Desk", 1, new DateTime(2026,2,10), "Pending"),
new(5, "Bob", "Headphones", 1, new DateTime(2026,3,3), "Delivered"),
new(6, "Alice", "Keyboard", 1, new DateTime(2026,3,15), "Delivered"),
new(7, "Dave", "Laptop", 2, new DateTime(2026,3,20), "Pending"),
new(8, "Carol", "Notebook", 5, new DateTime(2026,4,1), "Shipped"),
new(9, "Bob", "Webcam", 1, new DateTime(2026,4,5), "Cancelled"),
new(10,"Dave", "Mouse", 3, new DateTime(2026,4,10), "Pending"),
};Where — Filtering
var products = GetProducts();
// Simple filter
var electronics = products.Where(p => p.Category == "Electronics");
// Multiple conditions
var availableElectronics = products
.Where(p => p.Category == "Electronics" && p.Stock > 0 && p.IsActive);
// With complex logic
var interesting = products
.Where(p => (p.Price < 50m || p.Price > 500m) && p.IsActive);
// Filter with string operations
var hasPhone = products
.Where(p => p.Name.Contains("phone", StringComparison.OrdinalIgnoreCase));
// Chaining Where (AND logic)
var result = products
.Where(p => p.IsActive)
.Where(p => p.Stock > 0)
.Where(p => p.Price < 100m);
Console.WriteLine("Cheap, in-stock, active products:");
foreach (var p in result)
Console.WriteLine($" {p.Name}: {p.Price:C}");Select — Projection
Transform each element into a new shape:
// Project to anonymous type
var names = products.Select(p => p.Name);
// Project to a richer shape
var summaries = products
.Where(p => p.IsActive)
.Select(p => new
{
p.Name,
p.Category,
p.Price,
PriceRange = p.Price switch
{
< 10m => "Budget",
< 100m => "Mid-range",
< 500m => "Premium",
_ => "Luxury"
},
InStock = p.Stock > 0
});
foreach (var s in summaries)
Console.WriteLine($"{s.Name,-15} [{s.PriceRange}] {(s.InStock ? "In Stock" : "Out of Stock")}");
// Select with index
var numbered = products
.Select((p, index) => $"{index + 1}. {p.Name} — {p.Price:C}");
// Project to a named record/class
public record ProductSummary(string Name, decimal Price, bool InStock);
var typed = products.Select(p => new ProductSummary(p.Name, p.Price, p.Stock > 0));SelectMany — Flatten Nested Collections
public record OrderWithItems(string Customer, List<string> Items);
var customerOrders = new List<OrderWithItems>
{
new("Alice", new List<string> { "Laptop", "Mouse", "Keyboard" }),
new("Bob", new List<string> { "Monitor", "Headphones" }),
new("Carol", new List<string> { "Desk", "Chair" }),
};
// Flatten: get all items across all customers
var allItems = customerOrders
.SelectMany(o => o.Items);
// Laptop, Mouse, Keyboard, Monitor, Headphones, Desk, Chair
// Flatten with context (keep the parent info)
var itemsWithCustomer = customerOrders
.SelectMany(
o => o.Items,
(order, item) => new { order.Customer, Item = item }
);
foreach (var x in itemsWithCustomer)
Console.WriteLine($"{x.Customer} ordered {x.Item}");
// Flatten tags (each product has multiple tags)
public record TaggedProduct(string Name, List<string> Tags);
var tagged = new List<TaggedProduct>
{
new("Laptop", new List<string> { "electronics", "portable", "work" }),
new("Desk", new List<string> { "furniture", "work", "office" }),
};
var allTags = tagged.SelectMany(p => p.Tags).Distinct().OrderBy(t => t);
// electronics, furniture, office, portable, workOrderBy and ThenBy — Sorting
var products = GetProducts();
// Sort by one field
var byPrice = products.OrderBy(p => p.Price);
var byPriceDesc = products.OrderByDescending(p => p.Price);
// Sort by multiple fields
var sorted = products
.OrderBy(p => p.Category)
.ThenBy(p => p.Price)
.ThenByDescending(p => p.Name);
Console.WriteLine("Products sorted by category, then price:");
foreach (var p in sorted)
Console.WriteLine($" {p.Category,-12} {p.Price,8:C} {p.Name}");
// Custom comparer
var byNameLength = products
.OrderBy(p => p.Name.Length)
.ThenBy(p => p.Name);GroupBy — Grouping
var products = GetProducts();
// Group by category
var byCategory = products.GroupBy(p => p.Category);
foreach (var group in byCategory.OrderBy(g => g.Key))
{
Console.WriteLine($"\n{group.Key} ({group.Count()} products):");
foreach (var p in group.OrderBy(p => p.Name))
Console.WriteLine($" {p.Name}: {p.Price:C}");
}
// GroupBy with projection
var categorySummary = products
.GroupBy(p => p.Category)
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
TotalValue = g.Sum(p => p.Price * p.Stock),
AveragePrice = g.Average(p => p.Price),
MostExpensive = g.Max(p => p.Price)
})
.OrderByDescending(s => s.TotalValue);
Console.WriteLine("\nCategory Summary:");
foreach (var s in categorySummary)
{
Console.WriteLine($"{s.Category,-12}: {s.Count} products, " +
$"avg {s.AveragePrice:C}, " +
$"inventory value {s.TotalValue:C}");
}
// Nested grouping
var byPriceRange = products
.GroupBy(p => p.Price switch
{
< 10m => "Under $10",
< 100m => "$10-$100",
< 500m => "$100-$500",
_ => "Over $500"
});Join — Combining Collections
var products = GetProducts();
var orders = GetOrders();
// Inner join — matching orders with products
var orderDetails = orders.Join(
products,
order => order.ProductName, // key from orders
product => product.Name, // key from products
(order, product) => new // result selector
{
order.Id,
order.CustomerName,
product.Name,
product.Category,
order.Quantity,
UnitPrice = product.Price,
TotalPrice = product.Price * order.Quantity,
order.Status,
order.OrderDate
}
);
Console.WriteLine("Order Details:");
foreach (var d in orderDetails.OrderBy(d => d.OrderDate))
{
Console.WriteLine($" Order {d.Id}: {d.CustomerName} bought {d.Quantity}x {d.Name} " +
$"({d.TotalPrice:C}) — {d.Status}");
}
// Group join (left outer join) — all customers and their orders
var customerSummary = from c in new[] { "Alice", "Bob", "Carol", "Dave", "Eve" }
join o in orders on c equals o.CustomerName into customerOrders
select new
{
Customer = c,
OrderCount = customerOrders.Count(),
TotalSpent = customerOrders
.Join(products, o => o.ProductName, p => p.Name,
(o, p) => o.Quantity * p.Price)
.Sum()
};
Console.WriteLine("\nCustomer Summary:");
foreach (var cs in customerSummary.OrderByDescending(c => c.TotalSpent))
Console.WriteLine($" {cs.Customer,-8}: {cs.OrderCount} orders, {cs.TotalSpent:C} total");Element Operators
var products = GetProducts();
// First / FirstOrDefault
var first = products.First(); // first element, throws if empty
var firstElec = products.First(p => p.Category == "Electronics");
var firstCheap = products.FirstOrDefault(p => p.Price < 5m); // null if not found
var firstMissing = products.FirstOrDefault(p => p.Name == "XYZ") ?? new("None",0,"None",0,false);
// Last / LastOrDefault
var lastProduct = products.Last();
var lastElec = products.LastOrDefault(p => p.Category == "Electronics");
// Single / SingleOrDefault — expects exactly one match
try
{
var onlyLaptop = products.Single(p => p.Name == "Laptop"); // OK
// var multiple = products.Single(p => p.Category == "Electronics"); // THROWS — multiple matches
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Single failed: {ex.Message}");
}
var maybeLaptop = products.SingleOrDefault(p => p.Name == "Laptop");
var notFound = products.SingleOrDefault(p => p.Name == "Unicorn"); // null
// ElementAt
var third = products.ElementAt(2); // throws if index out of range
var thirdOrDefault = products.ElementAtOrDefault(2); // null if out of rangeQuantifiers and Aggregates
var products = GetProducts();
// Any / All / Count
bool hasExpensive = products.Any(p => p.Price > 1000m); // false
bool allActive = products.All(p => p.IsActive); // false
int activeCount = products.Count(p => p.IsActive); // N
// Sum / Min / Max / Average
decimal totalValue = products.Sum(p => p.Price * p.Stock);
decimal minPrice = products.Min(p => p.Price);
decimal maxPrice = products.Max(p => p.Price);
double avgPrice = (double)products.Average(p => p.Price);
// MinBy / MaxBy (C# 10+) — returns the object with min/max
var cheapest = products.MinBy(p => p.Price);
var mostExpensive = products.MaxBy(p => p.Price);
Console.WriteLine($"Cheapest: {cheapest?.Name} at {cheapest?.Price:C}");
Console.WriteLine($"Most expensive: {mostExpensive?.Name} at {mostExpensive?.Price:C}");
// Aggregate — custom fold operation
string productList = products
.Select(p => p.Name)
.Aggregate((acc, name) => $"{acc}, {name}");
Console.WriteLine(productList);
// Aggregate with seed
decimal totalInventoryValue = products
.Aggregate(0m, (total, p) => total + p.Price * p.Stock);Skip and Take — Paging
var products = GetProducts();
var sorted = products.OrderBy(p => p.Name).ToList();
int pageSize = 3;
int pageNumber = 2; // 1-based
// Traditional Skip/Take
var page = sorted.Skip((pageNumber - 1) * pageSize).Take(pageSize);
Console.WriteLine($"Page {pageNumber} (size {pageSize}):");
foreach (var p in page)
Console.WriteLine($" {p.Name}");
// SkipWhile / TakeWhile
var afterDesk = products.SkipWhile(p => p.Name != "Desk").Skip(1); // skip everything before and including Desk
var untilExpensive = products.TakeWhile(p => p.Price < 100m);
// Chunk (C# 8+) — split into pages without manual Skip/Take
foreach (var chunk in sorted.Chunk(3))
{
Console.WriteLine($"Chunk of {chunk.Length}:");
foreach (var p in chunk)
Console.WriteLine($" {p.Name}");
}Conversion Operators
var products = GetProducts();
// ToList — materializes the query into a List<T>
List<Product> list = products.Where(p => p.IsActive).ToList();
// ToArray — materializes into T[]
Product[] arr = products.OrderBy(p => p.Name).ToArray();
// ToDictionary — key must be unique
Dictionary<string, Product> byName = products.ToDictionary(p => p.Name);
Dictionary<string, decimal> priceByName = products.ToDictionary(
p => p.Name,
p => p.Price
);
// Lookup — like dictionary but keys can have multiple values
ILookup<string, Product> byCategory = products.ToLookup(p => p.Category);
foreach (Product p in byCategory["Electronics"])
Console.WriteLine(p.Name);
// ToHashSet — for fast membership checks
HashSet<string> activeNames = products
.Where(p => p.IsActive)
.Select(p => p.Name)
.ToHashSet();
bool hasLaptop = activeNames.Contains("Laptop"); // O(1) lookupDeferred Execution
This is one of the most important LINQ concepts to understand:
var products = GetProducts();
// This does NOT execute yet — it just builds a query description
var query = products.Where(p => p.Price > 100m);
// Add more to the query — still not executed
var refined = query.OrderBy(p => p.Price);
Console.WriteLine("Query defined, not yet executed.");
// Execution happens when you iterate (foreach, ToList, First, Count, etc.)
foreach (var p in refined) // ← EXECUTES HERE
Console.WriteLine(p.Name);
// Each time you enumerate, it re-executes the query!
int count1 = refined.Count(); // executes
int count2 = refined.Count(); // executes again
// To execute once and reuse:
var materialized = refined.ToList(); // executes ONCE
int count3 = materialized.Count; // just reads a property
int count4 = materialized.Count; // same list, no re-execution
// Implication: query depends on current state of source
products.Add(new("Tablet", 599m, "Electronics", 10, true));
int countAfter = refined.Count(); // includes Tablet! (deferred)
int countCached = materialized.Count; // does NOT include Tablet (snapshot)IEnumerable vs IQueryable
// IEnumerable<T> — in-memory, LINQ to Objects
IEnumerable<Product> inMemory = products.Where(p => p.Price > 100m);
// The Where runs on the .NET runtime, in memory
// IQueryable<T> — deferred, translates to underlying query language (SQL, etc.)
// When using Entity Framework:
IQueryable<Product> dbQuery = dbContext.Products.Where(p => p.Price > 100m);
// This builds an SQL WHERE clause — no data loaded yet!
// Calling .ToList() sends the SQL to the database
// Practical difference:
// IEnumerable — loads ALL products from DB, then filters in memory
var bad = dbContext.Products.AsEnumerable().Where(p => p.Price > 100m).ToList();
// SELECT * FROM Products → load 10,000 rows → filter to 50 in memory
// IQueryable — sends filter to DB, loads only matching rows
var good = dbContext.Products.Where(p => p.Price > 100m).ToList();
// SELECT * FROM Products WHERE Price > 100 → load 50 rows
// The fix: keep it IQueryable as long as possible, materialize late
IQueryable<Product> baseQuery = dbContext.Products;
if (!string.IsNullOrEmpty(category))
baseQuery = baseQuery.Where(p => p.Category == category);
if (maxPrice.HasValue)
baseQuery = baseQuery.Where(p => p.Price <= maxPrice.Value);
var results = baseQuery.OrderBy(p => p.Name).ToList(); // ONE SQL queryReal Example: Comprehensive Product Report
public class ProductReport
{
public static void Generate(List<Product> products, List<Order> orders)
{
Console.WriteLine("=== PRODUCT CATALOG REPORT ===\n");
// 1. Overall stats
Console.WriteLine("--- Overall Stats ---");
Console.WriteLine($"Total products: {products.Count}");
Console.WriteLine($"Active products: {products.Count(p => p.IsActive)}");
Console.WriteLine($"Out of stock: {products.Count(p => p.Stock == 0)}");
Console.WriteLine($"Total categories: {products.Select(p => p.Category).Distinct().Count()}");
// 2. Price analysis
Console.WriteLine("\n--- Price Analysis ---");
var active = products.Where(p => p.IsActive).ToList();
Console.WriteLine($"Price range: {active.Min(p => p.Price):C} — {active.Max(p => p.Price):C}");
Console.WriteLine($"Average price: {active.Average(p => p.Price):C}");
Console.WriteLine($"Median price: {Median(active.Select(p => p.Price)):C}");
// 3. Category breakdown
Console.WriteLine("\n--- By Category ---");
var byCategory = products
.GroupBy(p => p.Category)
.Select(g => new
{
Category = g.Key,
Count = g.Count(),
ActiveCount = g.Count(p => p.IsActive),
AvgPrice = g.Average(p => p.Price),
TotalInventory = g.Sum(p => p.Stock),
InventoryValue = g.Sum(p => p.Price * p.Stock)
})
.OrderByDescending(g => g.InventoryValue);
foreach (var cat in byCategory)
{
Console.WriteLine($" {cat.Category,-12}: {cat.Count} products " +
$"({cat.ActiveCount} active), " +
$"avg {cat.AvgPrice:C}, " +
$"inventory {cat.TotalInventory} units " +
$"(value: {cat.InventoryValue:C})");
}
// 4. Order analysis — products and their order counts
Console.WriteLine("\n--- Most Ordered Products ---");
var orderCounts = orders
.Where(o => o.Status != "Cancelled")
.GroupBy(o => o.ProductName)
.Select(g => new
{
ProductName = g.Key,
OrderCount = g.Count(),
TotalQuantity = g.Sum(o => o.Quantity),
Revenue = g.Join(products,
o => o.ProductName, p => p.Name,
(o, p) => o.Quantity * p.Price).Sum()
})
.OrderByDescending(x => x.Revenue)
.Take(5);
foreach (var oc in orderCounts)
Console.WriteLine($" {oc.ProductName,-15}: {oc.OrderCount} orders, " +
$"{oc.TotalQuantity} units, {oc.Revenue:C} revenue");
// 5. Stock alerts
Console.WriteLine("\n--- Stock Alerts ---");
var lowStock = products
.Where(p => p.IsActive && p.Stock is > 0 and <= 5)
.OrderBy(p => p.Stock);
var outOfStock = products
.Where(p => p.IsActive && p.Stock == 0);
Console.WriteLine($"Low stock ({lowStock.Count()}):");
foreach (var p in lowStock)
Console.WriteLine($" {p.Name}: {p.Stock} remaining");
Console.WriteLine($"Out of stock ({outOfStock.Count()}):");
foreach (var p in outOfStock)
Console.WriteLine($" {p.Name}");
}
private static decimal Median(IEnumerable<decimal> source)
{
var sorted = source.OrderBy(x => x).ToArray();
int mid = sorted.Length / 2;
return sorted.Length % 2 == 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
}
// Usage:
ProductReport.Generate(GetProducts(), GetOrders());Common Mistakes
1. Multiple Enumeration
// Enumerates TWICE — expensive if the source is a database or file
var query = products.Where(p => p.Price > 100m);
if (query.Any()) // first enumeration
{
foreach (var p in query) // second enumeration
Console.WriteLine(p.Name);
}
// Fix: materialize once
var list = products.Where(p => p.Price > 100m).ToList();
if (list.Count > 0)
foreach (var p in list) Console.WriteLine(p.Name);2. Null Reference in Queries
// If Name could be null, Contains throws
// products.Where(p => p.Name.Contains("Laptop")); // possible NullReferenceException
// Use null-conditional or null check
products.Where(p => p.Name?.Contains("Laptop") == true);
products.Where(p => p.Name != null && p.Name.Contains("Laptop"));3. Using First/Single Without a Null Check Alternative
// Throws InvalidOperationException if not found
var p = products.First(p => p.Name == "NonExistent"); // CRASH
// Safe versions
var safe = products.FirstOrDefault(p => p.Name == "NonExistent"); // null
if (safe is not null) { ... }4. ToDictionary with Duplicate Keys
// Throws ArgumentException if there are duplicate keys
var dict = products.ToDictionary(p => p.Category); // CRASH — multiple electronics
// Use ToLookup for one-to-many, or filter first
var dict2 = products
.GroupBy(p => p.Category)
.ToDictionary(g => g.Key, g => g.First()); // keep only first per categoryKey Takeaways
- Deferred execution means queries run when iterated, not when defined — be aware of multiple enumeration.
IEnumerablevsIQueryable: keepIQueryablefor database queries;IEnumerablefor in-memory.- Method syntax is more commonly used; query syntax shines for complex joins.
FirstOrDefault/SingleOrDefaultare safer thanFirst/Singlewhen the element might not exist.ToList()materializes a query — call it once and store the result if you need it multiple times.GroupByreturnsIEnumerable<IGrouping<TKey, TElement>>— access the key with.Keyand elements by iterating the group.SelectManyflattens nested collections — essential when working with hierarchical data.
What's Next
- C# Async/Await — combine LINQ with async streams
- C# Bank Project — use LINQ for transaction history and reports
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.