Articles › C#
LINQ in C#: The Complete Practical Guide
Master LINQ in C#: filtering, projection, sorting, grouping, joins, deferred execution, IEnumerable vs IQueryable and the performance traps to avoid.
LINQ (Language Integrated Query) is one of the features that makes C# a joy to write. It gives you a single, expressive syntax for querying in-memory collections, databases (through EF Core), XML and more. This guide covers the operators you'll use daily, the concept that confuses most developers — deferred execution — and the performance traps that matter in real projects.
Two syntaxes, one engine
LINQ comes in two flavors that compile to the same thing:
var numbers = new[] { 5, 12, 8, 21, 3, 17 };
// Method syntax (most common)
var big = numbers.Where(n => n > 10).OrderBy(n => n);
// Query syntax (SQL-like)
var big2 = from n in numbers
where n > 10
orderby n
select n;
Method syntax is used far more in modern code; query syntax can be more readable for complex joins and let clauses. We'll use method syntax below.
Our sample data:
public record Employee(int Id, string Name, string Department, decimal Salary, DateTime HiredAt);
var employees = new List<Employee>
{
new(1, "Sara", "Engineering", 4200, new(2021, 3, 1)),
new(2, "Reza", "Engineering", 3800, new(2022, 7, 15)),
new(3, "Mina", "Design", 3100, new(2020, 1, 10)),
new(4, "Ali", "Engineering", 5100, new(2019, 9, 5)),
new(5, "Neda", "Marketing", 2900, new(2023, 2, 20)),
};
Filtering: Where
var seniorEngineers = employees
.Where(e => e.Department == "Engineering" && e.HiredAt.Year < 2022);
Where also has an overload with the element index: .Where((e, i) => i % 2 == 0).
Projection: Select and SelectMany
Select transforms each element:
var names = employees.Select(e => e.Name.ToUpper());
var cards = employees.Select(e => new { e.Name, Years = DateTime.Now.Year - e.HiredAt.Year });
SelectMany flattens nested collections:
var orders = customers.SelectMany(c => c.Orders); // all orders of all customers
var tags = posts.SelectMany(p => p.Tags).Distinct(); // every unique tag
Sorting
var sorted = employees
.OrderBy(e => e.Department)
.ThenByDescending(e => e.Salary);
Always use ThenBy for secondary keys. Calling OrderBy twice discards the first sort.
Element operators
| Operator | Returns | When empty | When multiple |
|---|---|---|---|
First() |
first match | throws | returns first |
FirstOrDefault() |
first match | default |
returns first |
Single() |
the only match | throws | throws |
SingleOrDefault() |
the only match | default |
throws |
Last() / LastOrDefault() |
last match | throws / default |
returns last |
Use Single when there must be exactly one result (lookup by unique key) — it documents intent and catches data bugs early. Since .NET 6 you can specify a fallback: FirstOrDefault(defaultValue).
Quantifiers and aggregates
bool anyDesigners = employees.Any(e => e.Department == "Design");
bool allPaid = employees.All(e => e.Salary > 0);
int engineers = employees.Count(e => e.Department == "Engineering");
decimal payroll = employees.Sum(e => e.Salary);
decimal average = employees.Average(e => e.Salary);
var topEarner = employees.MaxBy(e => e.Salary); // .NET 6+
Tip: To check if a collection has items, use
Any(), notCount() > 0.Anystops at the first element;Countmay enumerate everything (or run aCOUNT(*)on the whole table).
Grouping
var byDepartment = employees
.GroupBy(e => e.Department)
.Select(g => new
{
Department = g.Key,
Headcount = g.Count(),
AverageSalary = g.Average(e => e.Salary),
Newest = g.MaxBy(e => e.HiredAt)!.Name
})
.OrderByDescending(x => x.Headcount);
foreach (var d in byDepartment)
Console.WriteLine($"{d.Department}: {d.Headcount} people, avg {d.AverageSalary:N0}");
ToLookup is the eager cousin of GroupBy: it builds a dictionary-like structure you can query many times — lookup["Engineering"].
Joining
var departments = new[]
{
new { Name = "Engineering", Floor = 3 },
new { Name = "Design", Floor = 2 },
};
// Inner join
var withFloor = employees.Join(
departments,
e => e.Department,
d => d.Name,
(e, d) => new { e.Name, d.Floor });
// Left join: keep employees without a matching department
var leftJoin =
from e in employees
join d in departments on e.Department equals d.Name into gj
from d in gj.DefaultIfEmpty()
select new { e.Name, Floor = d?.Floor };
When querying EF Core, you rarely need explicit joins: navigation properties (order.Customer.Name) generate the joins for you.
Set operations and partitioning
var a = new[] { 1, 2, 3, 4 };
var b = new[] { 3, 4, 5 };
a.Union(b); // 1 2 3 4 5
a.Intersect(b); // 3 4
a.Except(b); // 1 2
a.Concat(b); // 1 2 3 4 3 4 5
employees.DistinctBy(e => e.Department); // .NET 6+
employees.Skip(10).Take(10); // paging
employees.Chunk(100); // batches of 100 (.NET 6+)
Deferred execution: the most important concept
Most LINQ operators don't run when you call them. They build a pipeline that runs only when you enumerate the result:
var list = new List<int> { 1, 2, 3 };
var query = list.Where(n => n > 1); // nothing executed yet
list.Add(4);
foreach (var n in query) // executed now: 2, 3, 4
Console.WriteLine(n);
This has two consequences:
- Re-enumeration repeats the work. Enumerating the same query twice runs it twice — against a database, that's two round trips.
- The data may change between definition and execution.
When you need a snapshot, or will use the results more than once, materialize them with ToList(), ToArray() or ToDictionary().
Operators like Count(), First(), Sum() and ToList() execute immediately.
IEnumerable vs IQueryable
This distinction is crucial with EF Core:
IEnumerable<T>runs operators in memory, as C# delegates.IQueryable<T>builds an expression tree that a provider (EF Core) translates to SQL.
// ✅ Filter runs in SQL Server: WHERE Salary > 4000
var rich = db.Employees.Where(e => e.Salary > 4000).ToList();
// ❌ Loads the whole table, then filters in memory
IEnumerable<Employee> all = db.Employees;
var rich2 = all.Where(e => e.Salary > 4000).ToList();
Keep queries as IQueryable until the last moment, and only call ToList() once the query is fully shaped.
Performance traps
- Calling
ToList()too early — everything after it runs in memory. - Multiple enumeration of an expensive
IEnumerable— materialize once. Count() > 0instead ofAny().- N+1 queries — looping over results and querying related data inside the loop. Use
Includeor projection. - Untranslatable expressions — calling your own C# methods inside an EF Core
Wherethrows at runtime because SQL can't run them. - LINQ in very hot paths — for tight loops over arrays in performance-critical code, a plain
forloop can be faster. Measure before optimizing.
Summary
LINQ gives you a consistent vocabulary — Where, Select, OrderBy, GroupBy, Join, Any — across collections and databases. Understand deferred execution, keep EF Core queries as IQueryable until they're complete, and materialize deliberately. With those habits LINQ code is both elegant and efficient.
FAQ
Is LINQ slower than loops?
For in-memory collections there's a small overhead from delegates and enumerators. In most business code it's negligible compared to I/O; optimize only proven hot paths.
When should I use query syntax?
When a query has several joins, let variables or groupings, query syntax can read more naturally. Mixing both styles is perfectly fine.
What does "could not be translated" mean in EF Core?
Part of your LINQ expression has no SQL equivalent (often a custom C# method). Rewrite it with translatable operations, or move that part after AsEnumerable() once the data set is small.