Building a Product Search
Imagine you're building an e-commerce product catalog. Users need to filter by category, price range, and availability. You could write nested foreach loops with if statements, but that quickly becomes unreadable when you need to combine multiple criteria or project results into different shapes.
LINQ query syntax gives you a declarative way to express these filters. The from, where, and select keywords let you describe what data you want, not how to iterate through collections. Your code reads like a SQL query, making intent clear to anyone who reviews it.
You'll learn how from defines the data source, where filters items based on conditions, select transforms results into the shape you need, and how the in operator checks membership in collections. By the end, you'll build readable queries that replace complex loops with clear, maintainable expressions.
Understanding from and select
Every LINQ query starts with from to specify the data source and ends with select to define what to return. The from clause introduces a range variable that represents each element as you iterate through the collection. Think of it like a foreach variable but in a query context.
The select clause determines the output shape. You can return the entire item, specific properties, or construct new anonymous types with just the data you need. This projection step happens for each item that passes through your query.
var products = new List<Product>
{
new() { Id = 1, Name = "Laptop", Price = 999.99m, Category = "Electronics" },
new() { Id = 2, Name = "Desk", Price = 299.00m, Category = "Furniture" },
new() { Id = 3, Name = "Mouse", Price = 25.50m, Category = "Electronics" },
new() { Id = 4, Name = "Chair", Price = 199.99m, Category = "Furniture" }
};
// Select all products
var allProducts = from p in products
select p;
// Project to anonymous type with only name and price
var simplified = from p in products
select new { p.Name, p.Price };
Console.WriteLine("Simplified View:");
foreach (var item in simplified)
{
Console.WriteLine($"{item.Name}: ${item.Price}");
}
// Output:
// Laptop: $999.99
// Desk: $299.00
// Mouse: $25.50
// Chair: $199.99
record Product
{
public int Id { get; init; }
public string Name { get; init; }
public decimal Price { get; init; }
public string Category { get; init; }
}
The first query returns complete Product objects. The second query projects each product into an anonymous type containing only Name and Price, reducing memory usage and simplifying the data structure. This projection is powerful when you need to send data to APIs or UI components that don't need all properties.
Filtering with where
The where clause filters items based on boolean conditions. Place it between from and select to include only items that meet your criteria. You can use any expression that evaluates to true or false, including property comparisons, method calls, and complex boolean logic.
Multiple where clauses combine with AND logic. Each additional filter narrows the results further. This lets you build up complex filters incrementally while keeping each condition readable.
var inventory = new List<InventoryItem>
{
new() { Name = "Monitor", Price = 349.00m, InStock = true, Category = "Electronics" },
new() { Name = "Keyboard", Price = 79.99m, InStock = false, Category = "Electronics" },
new() { Name = "Desk Lamp", Price = 45.00m, InStock = true, Category = "Office" },
new() { Name = "Notebook", Price = 12.99m, InStock = true, Category = "Office" }
};
// Find affordable items in stock
var affordable = from item in inventory
where item.Price < 100
where item.InStock
select item;
Console.WriteLine("Affordable In-Stock Items:");
foreach (var item in affordable)
{
Console.WriteLine($"{item.Name}: ${item.Price}");
}
// Combine conditions with && for same result
var combined = from item in inventory
where item.Price < 100 && item.InStock
select item;
// Output:
// Desk Lamp: $45.00
// Notebook: $12.99
record InventoryItem
{
public string Name { get; init; }
public decimal Price { get; init; }
public bool InStock { get; init; }
public string Category { get; init; }
}
Both queries produce identical results. The first uses two separate where clauses for readability, while the second combines conditions with &&. Choose the style that makes your query's intent clearest. Separate clauses work well when each filter represents a distinct business rule.
Checking Membership with in
The in operator (available in C# 12 and later) checks if a value exists in a collection. Use it within where clauses to filter items where a property matches any value in a list. This is cleaner than chaining multiple OR conditions.
Behind the scenes, in translates to Contains method calls. For database queries via Entity Framework, it generates SQL IN clauses. Use HashSet for the lookup collection when checking against many values to maintain O(1) lookup performance.
var orders = new List<Order>
{
new() { OrderId = 101, Status = "Shipped", Total = 150.00m },
new() { OrderId = 102, Status = "Pending", Total = 75.00m },
new() { OrderId = 103, Status = "Delivered", Total = 200.00m },
new() { OrderId = 104, Status = "Cancelled", Total = 50.00m },
new() { OrderId = 105, Status = "Shipped", Total = 125.00m }
};
var activeStatuses = new[] { "Shipped", "Delivered" };
// Find orders with active statuses using in operator (C# 12+)
var activeOrders = from order in orders
where order.Status in activeStatuses
select order;
Console.WriteLine("Active Orders:");
foreach (var order in activeOrders)
{
Console.WriteLine($"Order {order.OrderId}: {order.Status} - ${order.Total}");
}
// Pre-C# 12 alternative using Contains
var activeOrdersLegacy = from order in orders
where activeStatuses.Contains(order.Status)
select order;
// Output:
// Order 101: Shipped - $150.00
// Order 103: Delivered - $200.00
// Order 105: Shipped - $125.00
record Order
{
public int OrderId { get; init; }
public string Status { get; init; }
public decimal Total { get; init; }
}
The in operator makes membership checks readable and concise. If you're on an earlier C# version, use Contains as shown in the legacy example. Both approaches compile to efficient code, but in reads more naturally in query syntax.
Advanced Projection Techniques
Select clauses can transform data in powerful ways. You can calculate new values, call methods, combine properties, or create nested objects. This lets you shape data exactly as your application needs it without modifying the original collection.
Projection runs for every item that passes through the query. Keep calculations simple or extract complex logic into methods to maintain performance and readability.
var employees = new List<Employee>
{
new() { FirstName = "Alice", LastName = "Johnson", Salary = 75000, Department = "Engineering" },
new() { FirstName = "Bob", LastName = "Smith", Salary = 65000, Department = "Sales" },
new() { FirstName = "Carol", LastName = "Williams", Salary = 85000, Department = "Engineering" }
};
// Project to display-friendly format with calculated fields
var display = from emp in employees
select new
{
FullName = $"{emp.FirstName} {emp.LastName}",
emp.Department,
MonthlyPay = emp.Salary / 12,
YearlyBonus = emp.Salary * 0.10m
};
Console.WriteLine("Employee Summary:");
foreach (var item in display)
{
Console.WriteLine($"{item.FullName} ({item.Department})");
Console.WriteLine($" Monthly: ${item.MonthlyPay:F2}, Bonus: ${item.YearlyBonus:F2}");
}
// Output:
// Alice Johnson (Engineering)
// Monthly: $6250.00, Bonus: $7500.00
// Bob Smith (Sales)
// Monthly: $5416.67, Bonus: $6500.00
// Carol Williams (Engineering)
// Monthly: $7083.33, Bonus: $8500.00
record Employee
{
public string FirstName { get; init; }
public string LastName { get; init; }
public decimal Salary { get; init; }
public string Department { get; init; }
}
This query creates a new anonymous type with computed fields. FullName combines first and last names, while MonthlyPay and YearlyBonus perform calculations. The original Employee objects remain unchanged. This immutability prevents accidental side effects and makes queries safe to reuse.
Combining Filters and Projections
Real queries typically combine filtering and projection. Start with from to define your source, add where clauses to filter, then use select to shape the output. This three-step pattern handles most data query needs clearly and concisely.
LINQ executes queries lazily by default. The query doesn't run until you enumerate results with foreach, ToList, or similar operations. This lets you build complex queries step by step and execute them only when needed.
var transactions = new List<Transaction>
{
new() { Id = 1, Amount = 150.00m, Type = "Purchase", Date = new DateTime(2025, 11, 1) },
new() { Id = 2, Amount = 50.00m, Type = "Refund", Date = new DateTime(2025, 11, 2) },
new() { Id = 3, Amount = 200.00m, Type = "Purchase", Date = new DateTime(2025, 11, 3) },
new() { Id = 4, Amount = 75.00m, Type = "Purchase", Date = new DateTime(2025, 11, 4) }
};
var targetTypes = new[] { "Purchase" };
var minAmount = 100.00m;
// Find significant purchases and format for reporting
var report = from t in transactions
where t.Type in targetTypes
where t.Amount >= minAmount
select new
{
TransactionId = t.Id,
FormattedAmount = $"${t.Amount:F2}",
DaysAgo = (DateTime.Now - t.Date).Days
};
Console.WriteLine("Significant Purchases:");
foreach (var item in report)
{
Console.WriteLine(
$"ID {item.TransactionId}: {item.FormattedAmount} ({item.DaysAgo} days ago)");
}
record Transaction
{
public int Id { get; init; }
public decimal Amount { get; init; }
public string Type { get; init; }
public DateTime Date { get; init; }
}
This query filters to purchase transactions over $100, then projects results into a reporting format with formatted amounts and calculated days. The two-stage approach keeps filtering logic separate from presentation concerns, making the code easier to test and modify.
From Loops to Queries
When you inherit code with imperative loops, refactoring to LINQ can improve readability dramatically. Start by identifying the loop's intent: is it filtering, transforming, or both? Then express that intent declaratively.
Step 1 - Identify the pattern: Look at what the loop does. If it uses if statements to skip items, that's filtering. If it creates new objects or modifies a result collection, that's projection. Most loops do both.
Step 2 - Extract filters to where: Convert if conditions into where clauses. Each if becomes a separate where or gets combined with &&. This makes filtering logic explicit and testable.
Step 3 - Move transformations to select: If the loop builds new objects, that logic moves to select. Anonymous types work well for temporary data structures that don't need formal class definitions.
// Before: Imperative loop
var results = new List<string>();
foreach (var product in products)
{
if (product.Price < 50 && product.InStock)
{
results.Add($"{product.Name} (${product.Price})");
}
}
// After: Declarative LINQ query
var results = from p in products
where p.Price < 50
where p.InStock
select $"{p.Name} (${p.Price})";
// The query is clearer about intent:
// "Select formatted strings from affordable in-stock products"
The LINQ version removes boilerplate (result list creation, Add calls) and states intent directly. Filters are explicit where clauses, and formatting happens in select. This refactoring pattern works for most filtering and projection loops.
Try It Yourself
Build a console app that filters and projects a collection using LINQ query syntax. This example demonstrates from, where, select, and in working together.
Steps
- Init a console project:
dotnet new console -n QueryDemo
- Navigate:
cd QueryDemo
- Replace Program.cs with the code below
- Modify your .csproj as shown
- Execute:
dotnet run
var books = new List<Book>
{
new() { Title = "Clean Code", Author = "Martin", Year = 2008, Genre = "Programming", Pages = 464 },
new() { Title = "The Pragmatic Programmer", Author = "Hunt", Year = 1999, Genre = "Programming", Pages = 352 },
new() { Title = "Design Patterns", Author = "Gamma", Year = 1994, Genre = "Programming", Pages = 416 },
new() { Title = "Sapiens", Author = "Harari", Year = 2011, Genre = "History", Pages = 443 },
new() { Title = "Educated", Author = "Westover", Year = 2018, Genre = "Biography", Pages = 334 }
};
var techGenres = new[] { "Programming", "Technology" };
// Find recent tech books and format for display
var techBooks = from book in books
where book.Genre in techGenres
where book.Year >= 2000
select new
{
book.Title,
book.Author,
Summary = $"{book.Pages} pages, published {book.Year}"
};
Console.WriteLine("Recent Tech Books:");
foreach (var book in techBooks)
{
Console.WriteLine($"{book.Title} by {book.Author}");
Console.WriteLine($" {book.Summary}");
}
record Book
{
public string Title { get; init; }
public string Author { get; init; }
public int Year { get; init; }
public string Genre { get; init; }
public int Pages { get; init; }
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console
Recent Tech Books:
Clean Code by Martin
464 pages, published 2008