C# LINQ Query vs Method Syntax: What the Compiler Does

We printed the calls C# generates for from, where, orderby, let and multiple from, measured let's hidden allocations, and checked .NET 9/10 additions.

Revised November 2026. This article began as a Korean LINQ tutorial in 2021. We recompiled it on .NET 10.0.14 (C# 14). The first query did not compile because the range variable was declared as women but used as woman (error CS0103), the hand-written comparison loop failed on new List<Woman>; and a stray semicolon inside a lambda (CS1526, CS1026), and the text promised "age 20 and over" while the code filtered age > 20. Those are fixed below. The bigger change is the focus: this version prints the exact method calls the compiler generates from query syntax, then measures what let costs and checks which LINQ methods arrived in .NET 9 and .NET 10.

If you read C# codebases you will meet both from m in members where … select … and members.Where(…).Select(…), sometimes in the same file. This article is for developers who want to know whether those are really the same thing and when each one is the better choice. The short answer: query syntax is pure syntax sugar that the compiler rewrites into method calls, so there is no performance difference. The differences that matter are the hidden object let allocates per element, the way one comma changes what orderby means, and the many operators that query syntax simply cannot express. For a first introduction see LINQ basics; grouping and joins are covered in LINQ group by and join.

The 2021 examples on .NET 10

The original first query:

var woman = from women in womanList   // range variable: women
            where woman.age > 20      // but every clause says woman
            orderby woman.age
            select woman;

All three clauses refer to a name that does not exist:

Program.cs(23,15): error CS0103: The name 'woman' does not exist in the current context
Program.cs(24,17): error CS0103: The name 'woman' does not exist in the current context
Program.cs(25,16): error CS0103: The name 'woman' does not exist in the current context

The "without LINQ" comparison had two more slips:

Program.cs(22,45): error CS1526: A new expression requires an argument list or (), [], or {} after type
Program.cs(23,48): error CS1026: ) expected
Program.cs(23,49): error CS1513: } expected

It needs new List<Woman>(), the semicolon inside (w1, w2) => w1.age - w2.age; has to go, and add is Add. The final complete program compiled unchanged and printed four of the five records. The 20-year-old was missing, because > 20 excludes 20; "20 and over" needs >= 20.

Two statements in the 2021 text also needed correcting:

  • "The range variable in from holds no data." Half true. After compilation the range variable is a lambda parameter; it receives each element when the query is enumerated. It only looks empty because of deferred execution: defining a query runs nothing.
  • "select extracts the data as an array." It does not. The result is a lazy IEnumerable<T> (or IOrderedEnumerable<T> after orderby); you get an array only when you call ToArray(). Also, since .NET 6 the project templates enable ImplicitUsings, so using System.Linq; is no longer required.

From here on the sample data is a neutral team roster:

record Member(string Name, int Age, string Team);

Member[] members =
[
    new("Kim", 34, "app"), new("Lee", 37, "web"), new("Park", 20, "app"),
    new("Choi", 24, "web"), new("Jung", 47, "app"), new("Kang", 24, "app"),
];

Watching the compiler translate query syntax

The C# specification defines query expressions as a syntactic translation. When the compiler sees where, it looks for a method named Where with a suitable signature; it does not care whether that method lives in System.Linq.Enumerable. Any type with the right methods can be the source of a query.

That makes the translation observable. We wrote a small wrapper, Q<T>, whose methods take Expression<Func<…>> instead of Func<…>. Expression trees can be printed, so each call logs the lambda the compiler produced (abridged):

using System.Linq.Expressions;

sealed class Q<T>(IEnumerable<T> src)
{
    public IEnumerable<T> Items => src;
    public Q<T> Where(Expression<Func<T, bool>> p)
    { Console.WriteLine($"  .Where({p})"); return new(src.Where(p.Compile())); }
    public Q<R> Select<R>(Expression<Func<T, R>> f)
    { Console.WriteLine($"  .Select({f})"); return new(src.Select(f.Compile())); }
    // SelectMany, OrderBy, OrderByDescending, GroupBy, ThenBy … the same way
}

Six queries against it:

var q = new Q<Member>(members);
var r1 = from m in q where m.Age >= 20 select m;
var r2 = from m in q where m.Age >= 20 orderby m.Age, m.Name descending select m.Name;
var r3 = from m in q let decade = m.Age / 10 * 10 where decade == 20 select $"{m.Name}({decade}s)";
var r4 = from m in q select m;
var r5 = from o in orders from item in o.Items select $"{o.Customer}:{item}";
var r6 = from m in q group m.Name by m.Team;

The real output:

1) where + select (identity select)
  .Where(m => (m.Age >= 20))
2) where + orderby + select projection
  .Where(m => (m.Age >= 20))
  .OrderBy(m => m.Age)
  .ThenByDescending(m => m.Name)
  .Select(m => m.Name)
   => Park, Kang, Choi, Kim, Lee, Jung
3) let
  .Select(m => new <>f__AnonymousType0`2(m = m, decade = ((m.Age / 10) * 10)))
  .Where(<>h__TransparentIdentifier0 => (<>h__TransparentIdentifier0.decade == 20))
  .Select(<>h__TransparentIdentifier0 => Format("{0}({1}s)", <>h__TransparentIdentifier0.m.Name, Convert(<>h__TransparentIdentifier0.decade, Object)))
   => Park(20s), Choi(20s), Kang(20s)
4) degenerate query
  .Select(m => m)
5) two from clauses
  .SelectMany(o => o.Items, (o, item) => Format("{0}:{1}", o.Customer, item))
   => Kim:pen, Kim:ink, Lee:pad
6) group by
  .GroupBy(m => m.Team, m => m.Name)
   => app: Kim,Park,Jung,Kang
   => web: Lee,Choi

What the output tells us:

  • Query 1: a trailing select m after where does nothing, so the compiler dropped the Select call entirely.
  • Query 4: with no other clause, select m survives as Select(m => m). The rule exists so that a query never hands back the source collection itself; ReferenceEquals(query, array) printed False.
  • Query 2: the key after the comma becomes ThenByDescending.
  • Query 3: let wraps the range variable and the new value in an anonymous-type object per element; later clauses read them through a compiler-named "transparent identifier".
  • Query 5: a second from becomes SelectMany, with the select folded into the result-selector lambda.
  • Query 6: group A by B becomes GroupBy(key, element) and needs no select.

The Format(…) and Convert(…, Object) appear because expression trees represent string interpolation as string.Format. When the same lambda compiles to an ordinary delegate, the interpolated-string handler is used instead and nothing is boxed.

Two-column diagram mapping query clauses to method calls. where becomes Where. orderby a, b descending becomes OrderBy then ThenByDescending. let becomes a Select that wraps the range variable and the new value in an anonymous object per element. A second from becomes SelectMany with a result selector. A select m that follows where is omitted entirely.
How the C# compiler rewrites each query clause, as printed by our expression-tree wrapper on .NET 10. let is the only clause that adds an object per element.

let and multiple from: convenient, not free

let is a query-syntax-only convenience: compute something once and use it in several clauses. Method syntax has no equivalent keyword; you would write Select(m => new { m, decade = … }) yourself. As the output shows, that means one object per element. To put a number on it we counted members in their twenties among one million records (random ages 10–69). Setup: .NET 10.0.14, Release build, Apple M1 Pro; each sample is 20 iterations, median of 7 samples.

(from m in src where m.Age / 10 * 10 == 20 select m.Name).Count();             // query
src.Where(m => m.Age / 10 * 10 == 20).Select(m => m.Name).Count();             // method
(from m in src let d = m.Age / 10 * 10 where d == 20 select m.Name).Count();   // let
src.Select(m => (m, d: m.Age / 10 * 10)).Where(t => t.d == 20).Select(t => t.m.Name).Count(); // value tuple
Variant Median Min–max Allocated per run
Query syntax, no let 3.29 ms 3.18–4.98 106 B
Same method chain 3.33 ms 3.18–4.56 106 B
Query syntax + let 16.54 ms 12.72–45.45 32,000,170 B (30.5 MB)
Method + value tuple 5.62 ms 5.44–6.97 186 B
foreach 2.75 ms 2.66–3.80 2 B
Bar chart. Query syntax without let 3.29 ms, the same method chain 3.33 ms, query syntax with let 16.54 ms, method chain with a value tuple 5.62 ms, plain foreach 2.75 ms. Query and method syntax are the same; let is about five times slower.
Counting matches in 1,000,000 records, median of 7 samples (20 iterations each). .NET 10.0.14, Release build, Apple Silicon Mac (M1 Pro). The let version allocated 30.5 MB per run; the others allocated under 200 bytes.

Query and method syntax were identical within noise, as they must be. let allocated exactly 32 bytes per element, one small object, and the extra GC work also made it the most erratic case (45 ms worst). Switching to a value tuple removed the allocation and brought it down to 5.6 ms. There is no reason to ban let in ordinary code, but in a loop that runs thousands of times per second, inline the expression into where.

Multiple from clauses flatten one-to-many data, such as every line item of every order. The method version is orders.SelectMany(o => o.Items, (o, item) => …). As soon as the result needs both the outer o and the inner item, the query form reads better, and with a third from the method form needs hand-written anonymous types. One warning: if you are matching two collections by key, do not nest from with a where. That is O(N×M); join builds a hash table and is dramatically faster (measured in LINQ group by and join).

orderby, ThenBy and sort stability

Enumerable.OrderBy is a stable sort: elements with equal keys keep their original order. Choi and Kang are both 24, and Choi comes first in the source array, so Choi comes first in the result.

from m in members orderby m.Age select m.Name
from m in members orderby m.Team, m.Age select $"{m.Team}/{m.Name}"
from m in members orderby m.Team orderby m.Age select $"{m.Team}/{m.Name}"
orderby Age          : Park, Choi, Kang, Kim, Lee, Jung
orderby Team, Age    : app/Park, app/Kang, app/Kim, app/Jung, web/Choi, web/Lee
orderby Team orderby Age: app/Park, app/Kang, web/Choi, app/Kim, web/Lee, app/Jung

The comma version becomes OrderBy(Team).ThenBy(Age): grouped by team, then by age. Writing orderby twice becomes OrderBy(Team).OrderBy(Age), which re-sorts everything by age, and the compiler gives no warning. The team order survives only between Kang and Choi, who share an age, and only because the sort is stable. Spelling out ascending as the 2021 article suggested is a matter of taste; checking where the comma is matters more.

What query syntax cannot do, and the .NET 9/10 additions

Query syntax has a small vocabulary: from, where, select, orderby, let, join, group, into. There is no keyword for Count, Take, Distinct, First or ToList; you wrap the query in parentheses and continue with methods. C# 13 and C# 14 did not change query syntax at all. Everything new arrived in the library, and it is all method syntax:

foreach (var kv in members.CountBy(m => m.Team)) Console.Write($"{kv.Key}={kv.Value} ");
foreach (var kv in members.AggregateBy(m => m.Team, 0, (max, m) => Math.Max(max, m.Age))) Console.Write($"{kv.Key}={kv.Value} ");
foreach (var (i, m) in members.Where(m => m.Team == "web").Index()) Console.Write($"{i}:{m.Name} ");
Console.WriteLine(string.Join(",", Enumerable.Sequence(0, 20, 5)));
Console.WriteLine(string.Join(",", Enumerable.InfiniteSequence(1, 2).Take(4)));
Console.WriteLine(string.Join(",", Enumerable.Range(1, 5).Shuffle()));
app=4 web=2
app=47 web=37
0:Lee 1:Choi
0,5,10,15,20
1,3,5,7
2,1,4,5,3

To verify the version claims we built the same file for net8.0;net9.0. On net8.0, CountBy, AggregateBy and Index failed with error CS1061 and passed on net9.0, so they are .NET 9 additions. Sequence and InfiniteSequence (error CS0117) and Shuffle (CS1061) failed on net9.0 as well, so they are .NET 10. Shuffle returns a different order on every run. Add to that the .NET 10 LeftJoin/RightJoin covered in LINQ basics.

Index() replaces the old Select((m, i) => …) numbering trick with a deconstructible tuple. CountBy and AggregateBy replace group … by followed by Count() and avoid building group objects, which cuts allocation sharply.

Choosing between the two

Situation Prefer
One filter, one sort, one projection Either; follow your team's style
An intermediate value used in several clauses Query syntax with let (value tuple on hot paths)
Flattening one-to-many, two or more from Query syntax
join or group … into continuations Query syntax
Count, Take, Distinct, First, CountBy, Index, Shuffle Method syntax (wrap the query and chain)
GroupBy/Distinct with an IEqualityComparer Method syntax
Two or more sort keys orderby a, b with a comma, never two orderby clauses

Query syntax is a readability tool, not a performance tool. Once you have seen the method calls the compiler emits, the rest follows: let allocates, a second orderby overrides the first, and a trailing select m can disappear.

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on Apple M1 Pro. Version claims checked by building the same source for net8.0, net9.0 and net10.0.

FAQ

Is LINQ query syntax slower than method syntax?

No. The compiler rewrites query syntax into ordinary Where, OrderBy and Select calls, so both compile to the same code. Filtering one million records took 3.29 ms with query syntax and 3.33 ms with the equivalent method chain (.NET 10, Release, Apple M1 Pro), which is within noise.

Does let in a LINQ query cost anything?

Yes. let creates one anonymous-type object per element to carry the range variable and the new value. Over one million elements the let version allocated 30.5 MB (32 bytes per element) and took a median 16.5 ms, about five times the 3.3 ms of the same query without let. On hot paths, inline the expression or use a value tuple.

Does writing orderby twice sort by both keys?

No. orderby a orderby b becomes OrderBy(a).OrderBy(b), which re-sorts everything by b. Because OrderBy is stable, only elements with equal b keep their a order. To sort by two keys, separate them with a comma, orderby a, b, which becomes OrderBy(a).ThenBy(b).

  • #C#
  • #LINQ
  • #query syntax
  • #SelectMany
  • #.NET 10

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑