AlgoMaster Logo

Query Syntax

Medium Priority19 min readUpdated June 6, 2026

Query syntax is C#'s SQL-flavored way of writing LINQ. Instead of chaining methods like source.Where(...).OrderBy(...).Select(...), you write a comprehension that reads top-to-bottom: from, where, orderby, select. This lesson covers the shape of every clause (from, where, select, orderby, group, join, let) and when query syntax tends to read better than the equivalent method chain.

The Shape of a Query

A query expression starts with from and ends with select or group. Everything in between filters, sorts, joins, or names intermediate values. The compiler rewrites the whole thing into a chain of extension methods, so a query expression and its method-chain equivalent compile to the exact same IL.

The clause from p in products introduces p as the range variable: a name for one element of the source. where p.Stock > 0 filters the sequence. select p.Name says what each output element looks like. The keywords from, where, select are LINQ-specific in this position. Outside a query expression, they're not reserved words.

The flow of a query expression mirrors how the data moves through the pipeline.

The source enters at from, gets filtered by where, sorted by orderby, projected by select, and exits as a result sequence. Add more clauses, the pipeline grows, but the direction is always source-to-result.

Why select Comes Last (and SQL Writes It First)

If you're coming from SQL, the position of select looks backwards. SQL writes SELECT name FROM products WHERE stock > 0. C# writes from p in products where p.Stock > 0 select p.Name. The reason is tooling, specifically IntelliSense.

When you type from p in products, the IDE now knows what type p is (the element type of products). Every clause after that gets full autocomplete on p's members. If select came first, the IDE wouldn't know what p was until it parsed the from, and you'd be typing into the dark.

SQL designed its syntax in the 1970s without an IDE in mind. C# designed query syntax in 2007 with Visual Studio in mind. Same data, different ergonomics.

The where Clause

where filters the sequence. The expression must evaluate to a bool for each element. You can have multiple where clauses, or combine conditions with && and || in a single one.

Three where clauses act like one combined condition with &&. The compiler stacks them into a single Where call on the chained form. Some teams prefer separate clauses because each line reads as its own rule, others prefer one where with && because it's shorter. Either compiles to the same thing.

A common mistake is using = instead of == inside a where. The single equals is assignment, not comparison, and the compiler will reject it.

The select Clause and Projection

select produces the output element for each input element. The expression can be the range variable itself (select p), a member of it (select p.Name), a new anonymous type (select new { p.Name, p.Price }), or any expression at all.

The shape of the projected element decides the type of the result. select p.Name produces IEnumerable<string>. select new { p.Name, p.Price } produces a sequence of an anonymous type. select p produces a sequence of the original element type.

You can also project into a known type, which is useful when you need to return data from a method:

Projecting into a named record is the usual pattern when the query result has to cross a method boundary. Anonymous types are fine inside the method that defines them, but they don't have a name you can write down in a method signature.

The orderby Clause

orderby sorts the sequence. By default it sorts ascending. Add descending after the key for the other direction. List multiple keys separated by commas to break ties.

The sort is stable and left-to-right: Category first (ascending, no keyword needed), then Price descending to break ties within each category. Webcam, Keyboard, and Mouse all share "Accessories", so the price key decides their order.

orderby materializes the whole source to sort. There's no such thing as a lazy sort. If the source is large and you only need the top few elements, consider sorting with OrderBy().Take(n) and let the runtime optimize, or use a different strategy entirely.

Compound from for Flattening

A second from clause iterates over a nested sequence. Each outer element produces zero or more inner elements, and the result is one flat sequence. This is the query-syntax equivalent of SelectMany.

Carol drops out of the result entirely because her Orders array is empty. The second from produces nothing for her, so nothing flows downstream. Each outer customer contributes one row per inner order.

You can mix where clauses with compound from to filter at either level:

The where filters the flattened sequence, so it sees each combined (customer, order) row and keeps only the rows where total >= 50m.

The let Clause

let names an intermediate value so you don't recompute it. It introduces a new range variable that stays in scope for the rest of the query.

Two let clauses give names to tax and total. Both are then available in the where, orderby, and select clauses. Without let, you'd be writing p.Price * p.TaxRate four times and hoping you didn't typo one of them.

Use let when the intermediate value gets used more than once or when naming it makes the query read better. A query with one let per line and short names usually beats the same logic crammed into a single select.

The group ... by ... Clause

group ends a query (just like select does) and produces a sequence of groups. Each group has a Key (the grouping value) and is itself a sequence of the elements that share that key.

Each group is iterable. category.Key is the value being grouped on ("Audio", "Accessories", "Displays"), and category itself is the collection of products in that group. Groups appear in the order their keys were first encountered in the source.

You can also project the group itself, picking a specific member instead of the whole element:

The group p.Name by p.Category form groups names by category instead of full products. The shape of group X by Y is "what to put in each group" followed by "what key to group on."

group ... by ... into ... Continuation

If you want to keep querying after the grouping (sort the groups, filter them, project them), use into to bind the groups to a new range variable and continue the query.

After group p by p.Category into g, the range variable g represents each group. The downstream orderby and select work on groups, not individual products. g.Count() and g.Average(...) are LINQ operators on the group itself.

The join Clause

join matches elements from two sequences on equal keys. The shape is join inner in innerSource on outerKey equals innerKey. The result has access to both the outer and the inner element.

Alice has two orders, both appear. Bob has one. Carol has none, so she's absent from the result. The order with CustomerId = 4 has no matching customer, so it also drops. The join clause is an inner join: only matched pairs survive.

The keyword is equals, not ==. This is a quirk specific to query syntax. equals here doesn't run Object.Equals; it's a special keyword that tells the compiler "this is the join condition." The expression on the left of equals must use only outer range variables, the expression on the right must use only inner range variables. The compiler enforces this so it can build an efficient hash-based join.

join ... into ... for Group Joins

Adding into after a join changes the shape: instead of one row per matching pair, you get one row per outer element, with the matched inner elements bundled as a sub-sequence. This is a group join.

The shape changed in two ways. First, Carol is included now (with zero orders) because group join keeps every outer element. Second, customerOrders inside the select is the grouped sub-sequence per customer, not a single matched pair. The result is one row per customer with all their orders bundled together.

Query Syntax vs Method Syntax: Same Output

Every query expression is rewritten by the compiler into a chain of extension method calls. The two forms produce identical IL. Here's the same query both ways.

The two foreach loops emit the same rows. Pick whichever form reads better for the query you're writing. Method syntax wins for simple single-operator queries (products.Where(p => p.Stock > 0) is shorter than the query form). Query syntax wins when the next section's patterns show up.

When Query Syntax Reads Better

Three patterns are noticeably cleaner in query syntax than in method syntax.

Pattern 1: Multiple joins. Each join is one line in query syntax. The method form requires nested lambdas with SelectMany and tuple result selectors, which gets ugly fast.

The method-syntax equivalent of a two-table join requires Join(orders, c => c.Id, o => o.CustomerId, (c, o) => new { c, o }).Join(products, ...), with anonymous types holding partial state between calls. The query form reads as three lines of relationships.

Pattern 2: Group-then-aggregate. When you group and then compute per-group totals, query syntax keeps the structure flat. Method syntax pushes the aggregation into a lambda inside Select.

The into g continuation gives a clean place to compute aggregates. The method form works, but you end up with GroupBy(o => o.Customer).Select(g => new { g.Key, Spent = g.Sum(x => x.Total) }).OrderByDescending(x => x.Spent), which packs three things into one line.

Pattern 3: `let` clauses. Naming an intermediate value is a single line in query syntax. Method syntax has no direct equivalent. The common workaround is to project into an anonymous type that carries the value forward, which adds visual noise.

The method-syntax version requires a Select(p => new { p, Total = p.Price * (1 + p.TaxRate) }).Where(x => x.Total < 50m).Select(x => new { x.p.Name, x.Total }), threading the computed value through anonymous types. The let form is one line.

Mixing Query and Method Syntax

You can call method-syntax operators on a query expression by wrapping the query in parentheses. This is common when you need an operator that has no query-syntax keyword, like Count, Sum, First, Take, or Distinct.

The pattern (from ... select ...).Operator() is common enough that most codebases use it without comment. It's not a special syntax; it's just calling an extension method on the result of the query expression.

A Few Rules to Remember

A handful of compile-time rules show up often.

  • A query must start with from and end with select or group. Anything else as a final clause is a syntax error.
  • The range variable from from is in scope until the next into or the end of the query.
  • let introduces a new range variable that joins the existing ones. It doesn't replace them.
  • After group ... into g or select ... into x, the original range variables go out of scope; only the new one survives.
  • Inside a join ... on X equals Y, X must reference only outer range variables and Y must reference only the join's inner variable.
  • The keywords from, where, select, orderby, group, by, into, let, join, in, on, equals, ascending, descending are contextual: they're only special inside a query expression. Outside one, you can have a variable named from if you want, though it's a bad idea.

Quiz

Query Syntax Quiz

10 quizzes