LINQ
Querying collections in C# with Language Integrated Query: filtering, projecting, sorting, grouping and aggregating with methods such as Where and Select, query syntax, and deferred execution.
What LINQ is
LINQ (Language Integrated Query) is the part of C# and .NET for querying data: filtering, transforming, sorting, grouping and summarizing it. It arrived with C# 3.0 in 2007, together with lambda expressions and extension methods, which it is built on. The same query style works on arrays and lists in memory, on XML, and — through libraries such as Entity Framework Core — on databases.
Without LINQ, "the total resolution time of all network tickets"
means a loop, an if and a running total. With LINQ you
describe the result, much like a
SQL query:
Ticket[] tickets =
[
new("INC-10231", "network", 2, 95),
new("INC-10232", "billing", 3, 40),
new("INC-10233", "network", 1, 210),
new("INC-10234", "portal", 2, 55),
];
// Without LINQ
int total = 0;
foreach (Ticket t in tickets)
{
if (t.Category == "network")
{
total += t.Minutes;
}
}
Console.WriteLine(total);
// With LINQ
Console.WriteLine(tickets.Where(t => t.Category == "network").Sum(t => t.Minutes));
record Ticket(string Id, string Category, int Priority, int Minutes);
305 305
The LINQ methods live in the namespace System.Linq,
which every new project and file-based app imports automatically.
They are extension methods on
IEnumerable<T>, the interface that every array,
list, set and dictionary implements (see
Loops), so they are available on all of
them. Each method takes a lambda — a Func delegate, see
Delegates, Lambdas and Events
— that is called for every element.
Method syntax: chaining calls
Most LINQ methods return a new sequence, so calls can be chained into a pipeline in which each step feeds the next. Writing queries as chained method calls with lambdas is called method syntax. It is the form you will see most in modern C# code. One step per line keeps it readable:
Ticket[] tickets =
[
new("INC-10231", "network", 2, 95),
new("INC-10232", "billing", 3, 40),
new("INC-10233", "network", 1, 210),
new("INC-10234", "portal", 2, 55),
new("INC-10235", "billing", 2, 30),
new("INC-10236", "network", 3, 25),
];
var urgentSlow = tickets
.Where(t => t.Priority <= 2)
.Where(t => t.Minutes > 50)
.OrderByDescending(t => t.Minutes)
.Select(t => $"{t.Id} ({t.Category}, {t.Minutes} min)")
.ToList();
foreach (string line in urgentSlow)
{
Console.WriteLine(line);
}
record Ticket(string Id, string Category, int Priority, int Minutes);
INC-10233 (network, 210 min) INC-10231 (network, 95 min) INC-10234 (portal, 55 min)
ToList() pulls the elements through the whole
pipeline.
Filtering and projecting
Where keeps the elements for which the lambda returns
true. Its lambda can also receive the element's index
as a second parameter. OfType<T>() filters by
type and Distinct() drops duplicates.
Select: projection
Select transforms every element into something else —
one output per input. This is called
projection, a word borrowed from
databases, where selecting columns "projects" a table onto fewer
columns. The result can be a single property, a calculated value, a
tuple, a record, or an anonymous type created with
new { … }, which is handy for a shape you need only
inside one method.
Reading[] readings =
[
new("GW-07", 12.5m),
new("GW-08", 48.0m),
new("GW-09", 7.25m),
];
const decimal Tariff = 0.32m;
IEnumerable<string> ids = readings.Select(r => r.MeterId);
Console.WriteLine(string.Join(", ", ids));
var costs = readings.Select(r => new { r.MeterId, Cost = r.Kwh * Tariff });
foreach (var c in costs)
{
Console.WriteLine($"{c.MeterId}: {c.Cost:F2}");
}
var numbered = readings.Select((r, index) => $"{index + 1}. {r.MeterId}");
Console.WriteLine(string.Join(" | ", numbered));
record Reading(string MeterId, decimal Kwh);
GW-07, GW-08, GW-09 GW-07: 4.00 GW-08: 15.36 GW-09: 2.32 1. GW-07 | 2. GW-08 | 3. GW-09
SelectMany projects every element to a
sequence and flattens the results into one sequence — for
example all meters of all customers:
Customer[] customers =
[
new("Amara Okafor", ["GW-07", "GW-08"]),
new("Mei Chen", ["GW-11"]),
];
var allMeters = customers.SelectMany(c => c.Meters);
Console.WriteLine(string.Join(", ", allMeters));
var pairs = customers.SelectMany(c => c.Meters, (c, meter) => $"{meter} → {c.Name}");
Console.WriteLine(string.Join("; ", pairs));
record Customer(string Name, string[] Meters);
GW-07, GW-08, GW-11 GW-07 → Amara Okafor; GW-08 → Amara Okafor; GW-11 → Mei Chen
Sorting
OrderBy and OrderByDescending sort by a
key that the lambda returns. For a second key, chain
ThenBy or ThenByDescending — calling
OrderBy twice would sort the whole sequence again and
throw the first order away. LINQ's sort is stable: elements
with equal keys keep their original order. For simple values,
Order() and OrderDescending() (.NET 7 and
later) sort by the value itself.
Ticket[] tickets =
[
new("INC-10231", "network", 2, 95),
new("INC-10232", "billing", 3, 40),
new("INC-10233", "network", 1, 210),
new("INC-10234", "portal", 2, 55),
new("INC-10235", "billing", 2, 30),
];
var sorted = tickets
.OrderBy(t => t.Priority)
.ThenByDescending(t => t.Minutes);
foreach (Ticket t in sorted)
{
Console.WriteLine($"P{t.Priority} {t.Minutes,4} min {t.Id}");
}
int[] temperatures = [17, 14, 21, 19];
Console.WriteLine(string.Join(" ", temperatures.Order()));
record Ticket(string Id, string Category, int Priority, int Minutes);
P1 210 min INC-10233 P2 95 min INC-10231 P2 55 min INC-10234 P2 30 min INC-10235 P3 40 min INC-10232 14 17 19 21
Strings are sorted with the current culture's rules by default. For
identifiers, codes and anything that must sort the same on every
computer, pass a comparer:
OrderBy(t => t.Id, StringComparer.Ordinal).
Aggregating and single elements
Some methods reduce a whole sequence to one value. They run the query immediately.
Ticket[] tickets =
[
new("INC-10231", "network", 2, 95),
new("INC-10232", "billing", 3, 40),
new("INC-10233", "network", 1, 210),
new("INC-10234", "portal", 2, 55),
];
Console.WriteLine($"Count: {tickets.Length}, urgent: {tickets.Count(t => t.Priority == 1)}");
Console.WriteLine($"Total: {tickets.Sum(t => t.Minutes)} min");
Console.WriteLine($"Average: {tickets.Average(t => t.Minutes):F1} min");
Console.WriteLine($"Longest: {tickets.Max(t => t.Minutes)} min, by {tickets.MaxBy(t => t.Minutes)!.Id}");
Console.WriteLine($"Any P3? {tickets.Any(t => t.Priority == 3)}");
Console.WriteLine($"All under 4 hours? {tickets.All(t => t.Minutes < 240)}");
Console.WriteLine($"First network ticket: {tickets.First(t => t.Category == "network").Id}");
string ids = tickets.Select(t => t.Id[4..]).Aggregate((acc, id) => acc + "+" + id);
Console.WriteLine(ids);
record Ticket(string Id, string Category, int Priority, int Minutes);
Count: 4, urgent: 1 Total: 400 min Average: 100.0 min Longest: 210 min, by INC-10233 Any P3? True All under 4 hours? True First network ticket: INC-10231 10231+10232+10233+10234
Max returns the largest key;
MaxBy and MinBy (.NET 6 and later) return
the element with the largest or smallest key.
Aggregate is the general form, like
reduce; reach for it only when no specific method fits.
For an array, Length is cheaper than
Count(); for a List<T>, use
Count.
Empty sequences
Methods that must return an element cannot do so for an empty
sequence, and throw an InvalidOperationException:
First, Last, Single, and for
value types such as int also Min,
Max and Average (for reference and
nullable types Min and Max return
null). Sum and Count return
0.
int[] outagesToday = [];
Console.WriteLine(outagesToday.Sum());
Console.WriteLine(outagesToday.FirstOrDefault());
Console.WriteLine(outagesToday.FirstOrDefault(-1));
Console.WriteLine(outagesToday.Average());
0 0 -1 Unhandled exception. System.InvalidOperationException: Sequence contains no elements
-
FirstOrDefault(),LastOrDefault()andSingleOrDefault()returndefault— 0 ornull— when nothing matches; since .NET 6 you can pass your own fallback value, as inFirstOrDefault(-1). -
Single()also throws when there is more than one element: use it when exactly one match is the only valid outcome, such as looking up a ticket by its unique id. -
Check
Any()beforeAverage(), or average a nullable projection, if the sequence may be empty.
Grouping
Grouping collects the
elements that share a key into groups. GroupBy returns
a sequence of groups; each group is itself a sequence of elements
(IGrouping<TKey, TElement>) with a
Key property. You can loop over a group or aggregate
it, much like SQL's GROUP BY. The groups come out in
the order in which each key first appears.
Ticket[] tickets =
[
new("INC-10231", "network", 2, 95),
new("INC-10232", "billing", 3, 40),
new("INC-10233", "network", 1, 210),
new("INC-10234", "portal", 2, 55),
new("INC-10235", "billing", 2, 30),
new("INC-10236", "network", 3, 25),
];
var perCategory = tickets
.GroupBy(t => t.Category)
.Select(g => new { Category = g.Key, Count = g.Count(), Minutes = g.Sum(t => t.Minutes) })
.OrderByDescending(x => x.Minutes);
foreach (var row in perCategory)
{
Console.WriteLine($"{row.Category,-8} tickets: {row.Count} minutes: {row.Minutes,3}");
}
foreach (var group in tickets.GroupBy(t => t.Priority).OrderBy(g => g.Key))
{
Console.WriteLine($"P{group.Key}: {string.Join(", ", group.Select(t => t.Id))}");
}
record Ticket(string Id, string Category, int Priority, int Minutes);
network tickets: 3 minutes: 330 billing tickets: 2 minutes: 70 portal tickets: 1 minutes: 55 P1: INC-10233 P2: INC-10231, INC-10234, INC-10235 P3: INC-10232, INC-10236
Two shortcuts help with grouped data. CountBy (.NET 9
and later) counts per key without building the groups, and
ToDictionary turns a sequence into a dictionary for
fast lookups by key (it throws if a key occurs twice):
string[] categories = ["network", "billing", "network", "portal", "billing", "network"];
foreach (KeyValuePair<string, int> pair in categories.CountBy(c => c).OrderBy(p => p.Key))
{
Console.WriteLine($"{pair.Key}: {pair.Value}");
}
Dictionary<string, decimal> tariffByCode = new[]
{
(Code: "standard", Price: 0.32m),
(Code: "night", Price: 0.24m),
}.ToDictionary(t => t.Code, t => t.Price);
Console.WriteLine(tariffByCode["night"]);
billing: 2 network: 3 portal: 1 0.24
Query syntax
C# also has keywords for LINQ that read like SQL. This
query syntax starts with
from and ends with select or
group; in between come where,
orderby, let (a local name inside the
query) and join. The compiler translates it into the
same method calls, so both forms run identically.
Ticket[] tickets =
[
new("INC-10231", "C-1042", 2, 95),
new("INC-10232", "C-2001", 3, 40),
new("INC-10233", "C-1042", 1, 210),
];
Customer[] customers =
[
new("C-1042", "Amara Okafor"),
new("C-2001", "Mei Chen"),
];
var query =
from t in tickets
where t.Priority <= 2
let hours = t.Minutes / 60.0
orderby hours descending
select $"{t.Id}: {hours:F1} h";
foreach (string line in query) Console.WriteLine(line);
var withNames =
from t in tickets
join c in customers on t.CustomerId equals c.Id
select new { t.Id, c.Name };
foreach (var row in withNames) Console.WriteLine($"{row.Id} → {row.Name}");
// The first query in method syntax:
var same = tickets
.Where(t => t.Priority <= 2)
.Select(t => new { t, hours = t.Minutes / 60.0 })
.OrderByDescending(x => x.hours)
.Select(x => $"{x.t.Id}: {x.hours:F1} h");
Console.WriteLine(same.SequenceEqual(query));
record Ticket(string Id, string CustomerId, int Priority, int Minutes);
record Customer(string Id, string Name);
INC-10233: 3.5 h INC-10231: 1.6 h INC-10231 → Amara Okafor INC-10232 → Mei Chen INC-10233 → Amara Okafor True
Which to use is mostly taste. Method syntax is more common and can
do everything; many methods (Count, Sum,
First, Take, Distinct) have
no keyword, so a query often ends in parentheses and a method call
anyway. Query syntax is easier to read for joins and for queries
that need let.
Deferred execution
A LINQ query is a recipe, not a result. Methods such as
Where, Select and
OrderBy return an object that describes the query;
nothing is computed until something enumerates it — a
foreach, or a method such as ToList(),
ToArray(), Count() or Sum().
This is
deferred execution. The
elements then stream through the pipeline one at a time:
int[] readings = [12, 48, 7, 63];
var high = readings.Where(r =>
{
Console.WriteLine($" checking {r}");
return r > 40;
});
Console.WriteLine("Query defined, nothing checked yet");
foreach (int r in high)
{
Console.WriteLine($"High reading: {r}");
}
Query defined, nothing checked yet checking 12 checking 48 High reading: 48 checking 7 checking 63 High reading: 63
Two consequences follow. First, the query sees the data as it is when it runs, not when it was written:
var openTickets = new List<string> { "INC-10231", "INC-10232" };
var query = openTickets.Where(id => id.StartsWith("INC-"));
List<string> snapshot = query.ToList(); // runs now and stores the result
openTickets.Add("INC-10233");
Console.WriteLine($"query: {string.Join(", ", query)}");
Console.WriteLine($"snapshot: {string.Join(", ", snapshot)}");
query: INC-10231, INC-10232, INC-10233 snapshot: INC-10231, INC-10232
Second, every enumeration runs the query again. Calling
Count() and then looping does the work twice — harmless
for a small array, expensive when each step reads a file or queries
a database:
int checks = 0;
int[] readings = [12, 48, 7, 63];
var high = readings.Where(r => { checks++; return r > 40; });
Console.WriteLine($"{high.Count()} high readings");
foreach (int r in high) Console.WriteLine(r);
Console.WriteLine($"Filter ran {checks} times for {readings.Length} readings");
checks = 0;
List<int> highList = high.ToList(); // run once, keep the result
Console.WriteLine($"{highList.Count} high readings");
foreach (int r in highList) Console.WriteLine(r);
Console.WriteLine($"Filter ran {checks} times");
2 high readings 48 63 Filter ran 8 times for 4 readings 2 high readings 48 63 Filter ran 4 times
LINQ and databases
Everything above is LINQ to Objects: the lambdas run in
your program, on data in memory. Entity Framework Core (EF Core),
the .NET
object-relational mapper, exposes tables as IQueryable<T>. On that type,
the LINQ methods do not run your lambdas: they collect the query as
an expression tree, and EF Core translates it into SQL that
the database executes. Values from your variables are sent as
parameters, not pasted into the SQL text, so a LINQ query is not open to SQL
injection.
// EF Core (not run here: needs a database and the EF Core package)
List<Ticket> open = await db.Tickets
.Where(t => t.Category == category && t.Priority <= 2)
.OrderBy(t => t.Id)
.Take(20)
.ToListAsync();
Roughly, the database receives a
SELECT … WHERE … ORDER BY … with a limit of 20 and
category as a parameter; the exact SQL depends on the
database provider. Deferred execution matters even more here: the
query is sent when ToListAsync() runs, and enumerating
the same IQueryable twice sends it twice. Not every C#
method can be translated to SQL; when EF Core cannot translate a
filter, sort or join, it throws an
InvalidOperationException instead of silently loading
the whole table; only the final Select may run partly
in your program. The await is explained in
Asynchronous Programming with async and await.