C# LINQ Basics, Re-run on .NET 10
from, where, orderby, select, group and join with examples, all re-run on .NET 10. Deferred execution, the new LeftJoin method, and measured speed against a plain loop.
Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran every example on .NET 10.0.14 (C# 14). The outputs matched the original, but one example did not compile because of a type-name typo, the code contained invisible BOM characters, and the printed output had lost its line breaks. Below are the fixed examples, plus deferred execution, .NET 10's
LeftJoinand measured speed.
LINQ (Language-Integrated Query) lets you query anything that looks like a collection directly in C#. Filtering, sorting and grouping that would take several loops and conditions become one expression.
Loop vs. LINQ
Picking the even numbers from an array and sorting them with a loop:
List<int> intList = new List<int>();
int[] numbers = { 1, 3, 4, 6, 5, 9, 8, 12, 15, 18, 17, 11, 22 };
foreach (int num in numbers)
{
if (num % 2 == 0)
intList.Add(num);
}
intList.Sort();
foreach (int num in intList)
Console.Write("{0} ", num);
4 6 8 12 18 22
The same thing as a LINQ query expression:
int[] numbers = { 1, 3, 4, 6, 5, 9, 8, 12, 15, 18, 17, 11, 22 };
var data = from num in numbers
where num % 2 == 0
orderby num
select num;
foreach (var i in data)
Console.Write("{0} ", i);
4 6 8 12 18 22
Or in method syntax, which is what the compiler turns query expressions into (Order() exists since .NET 7):
var data = numbers.Where(n => n % 2 == 0).Order();
from
Every query expression starts with from:
from rangeVariable in dataSource
The range variable works like a foreach loop variable but does not hold data itself. The source can be anything implementing IEnumerable<T> (or IQueryable<T>), such as arrays and List<T>.
where
where is a filter: only elements for which the condition is true pass through. You can use more than one.
var data = from num in numbers
where num < 10
orderby num
select num;
// 1 3 4 5 6 8 9
orderby
orderby sorts, ascending by default; add descending to reverse. Several keys are separated by commas. It compiles to OrderBy, OrderByDescending and ThenBy.
var data = from num in numbers
orderby num descending
select num;
// 22 18 17 15 12 11 9 8 6 5 4 3 1
select
select shapes the result, and its type sets the type of the whole query: selecting char gives IEnumerable<char>. A query expression must end with select or group.
char[] chars = "str12i3!@$1ng".ToCharArray();
var data = from vchar in chars
where vchar >= 97 && 122 >= vchar // 'a'..'z'
select vchar;
foreach (char c in data)
Console.Write("{0}", c);
// string
In real code, vchar >= 'a' && vchar <= 'z' or char.IsAsciiLetterLower(vchar) (.NET 7+) reads better than 97 and 122.
group
group A by B into C splits elements A by key B into groups C. into is needed only if you keep working with the groups.
var listStudent = new List<Student> // the 2021 version had List<student> and did not compile
{
new Student() { Name = "Kim Cheolsu", Average = 78.5 },
new Student() { Name = "Kim Younghee", Average = 91.2 },
new Student() { Name = "Hong Gildong", Average = 77.3 },
new Student() { Name = "Kim Gilsu", Average = 80.8 }
};
var queryStudent = from student in listStudent
orderby student.Average
group student by student.Average < 80.0;
foreach (var studentGroup in queryStudent)
{
Console.WriteLine(studentGroup.Key ? "Average below 80:" : "Average 80 or above:");
foreach (var student in studentGroup)
Console.WriteLine("\t{0}: {1}", student.Name, student.Average);
}
Average below 80:
Hong Gildong: 77.3
Kim Cheolsu: 78.5
Average 80 or above:
Kim Gilsu: 80.8
Kim Younghee: 91.2
The original's List<student> fails because C# is case-sensitive:
error CS0246: The type or namespace name 'student' could not be found
To group by score band, name the group with into and keep going:
var queryStudent = from student in listStudent
group student by (int)student.Average / 10 into g
orderby g.Key
select g;
foreach (var g in queryStudent)
{
Console.WriteLine("{0} to {1}:", g.Key * 10, g.Key * 10 + 10);
foreach (var student in g)
Console.WriteLine("\t{0}: {1}", student.Name, student.Average);
}
With seven students (adding 54.2, 61.4 and 90.8), the groups come out as 50–60, 60–70, 70–80 (two students), 80–90 and 90–100 (two students).
join
join connects two unrelated sources by a key. The comparison uses the equals keyword, not ==, and only equality is allowed.
Inner join
Only items present on both sides come out:
var query = from s in listAverage
join h in listHobby on s.Name equals h.Name
select new { s.Name, s.Average, h.Hobby };
Of seven students, the four with a hobby record appear.
Left join: the 2021 way and the .NET 10 way
To keep students without a hobby, you need a left (outer) join. In 2021 that looked like this:
var query = from s in listAverage
join h in listHobby on s.Name equals h.Name into sg
from h in sg.DefaultIfEmpty(new MyHobby() { Hobby = "none" })
select new { s.Name, h.Hobby };
.NET 10 adds a LeftJoin method, which is much simpler:
var query = listAverage.LeftJoin(listHobby,
s => s.Name, h => h.Name,
(s, h) => new { s.Name, Hobby = h?.Hobby ?? "none" });
On .NET 10 both versions return exactly the same seven rows, with "none" for the three students without a hobby.
The trap you must know: deferred execution
A LINQ query runs when you read it, not when you write it. This was the biggest gap in the 2021 article.
var src = new List<int> { 1, 2, 3 };
var evens = from n in src where n % 2 == 0 select n; // nothing has run yet
src.Add(4);
Console.WriteLine(string.Join(" ", evens)); // 2 4 <- the 4 added later appears
int calls = 0;
var q = src.Where(n => { calls++; return n > 0; });
_ = q.Count(); _ = q.Count();
Console.WriteLine(calls); // 10 <- 5 items x 2 runs, from scratch each time
If you need a fixed result or will use it more than once, materialize it once with ToList() or ToArray(). This matters most for database queries (IQueryable), which send a new query every time they are enumerated.
Speed against a loop (measured)
Sum of the even numbers among 10 million ints, Release build, .NET 10, Apple M1 Pro, three runs:
| Method | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
foreach + if |
15.4 ms | 7.8 ms | 8.1 ms |
Where(...).Sum(...) |
24.2 ms | 21.7 ms | 21.8 ms |
For simple loops LINQ was two to three times slower, because of delegate calls and enumerator overhead. At about 20 ms for 10 million items, readability still wins in most code. Consider a hand-written loop only on paths that run thousands of times per second.
Summary
| Clause | Purpose | Method syntax |
|---|---|---|
from … in |
source and range variable | — |
where |
filter | Where |
orderby |
sort | OrderBy, OrderByDescending, ThenBy |
select |
shape the result | Select |
group … by |
group | GroupBy |
join … on … equals |
connect | Join, GroupJoin, LeftJoin (.NET 10) |
See Microsoft's >LINQ documentation for more.
FAQ
When does a LINQ query actually run?
When you enumerate it (foreach, ToList, Count…), not when you define it. Items added to the source list after the query was defined still show up. Calling Count() twice on the same query runs the predicate twice.
How do I do a left outer join in LINQ?
Since .NET 10 there is a LeftJoin method. Before that, the pattern was join … into group, then from … in group.DefaultIfEmpty(). We verified on .NET 10 that both return identical results.
Is LINQ slower than a for/foreach loop?
For simple work, somewhat. Summing even numbers over 10 million ints took about 8 ms with foreach and about 22 ms with Where+Sum (Release, Apple M1 Pro). Readability usually matters more; optimize only genuinely hot loops.