Summary#
The LINQ verbs work over a local List<T> — where T is a class or a plain scalar like
string or int — not just over your entities. The same spellings, evaluated in memory:
class Line { public string Sku; public int Amount; }
int TopThreeTotal(List<Line> lines) {
var top = lines.Where(l => l.Amount > 0)
.OrderByDescending(l => l.Amount)
.Take(3);
return top.Sum(l => l.Amount);
}Signature#
List<T> list.Where(x => predicate) // filter
List<U> list.Select(x => new U { … }) // project into a class…
List<V> list.Select(x => x.Field) // …or into a scalar (→ List<string>, List<int>, …)
List<U> list.GroupBy(x => key).Select(g => new U { …g.Key, g.Count(), g.Sum(e => e.V)… })
T list.First(x => p) / .Single(x => p) // one element (First/Single fault when empty; …OrDefault → null)
bool list.Any(x => p) // any match?
bool list.All(x => p) // do they ALL match? (true for an empty list, as in C#)
int list.Count() / .Count(x => p) // how many
V list.Sum / Average / Min / Max(x => v) // aggregate a selected value
List<T> list.OrderBy / OrderByDescending(x => k) // sort
List<T> list.Skip(n) / .Take(n) / .Distinct() // page / dedupe
List<T> list.Union / Concat / Intersect / Except(other) // combine two lists (set operators)
List<T> list.ToList() // a real, indexable List<T>
int list.IndexOf(item) / .LastIndexOf(item) // WHERE is this element? first / last. -1 when absent
int list.FindIndex(x => p) / .FindLastIndex(x => p) // …and by PREDICATE: first / last match. -1 for noneThe source may be a List<T>, a HashSet<T>, an array (T[]), or any collection you already hold — including
the rows a query returned; either way a collection result is a real, indexable List<T>. T may be a class or a
scalar.
Description#
Given a List<T> of class values, the query verbs behave exactly as in C#: Where filters, First/Single
select one element (the …OrDefault forms return null instead of faulting on empty; Single faults when more than
one matches), Any/All/Count answer set questions, and Sum/Average/Min/Max fold a selected value — Sum and
Min/Max keep the selected value's type, Average is decimal. Skip/Take page; Distinct de-duplicates. A
collection result (Where(…), ToList()) is a real List<T> — indexable, .Count, foreach. Predicates and
selectors may capture local variables.
The verbs compose in any order, matching C# LINQ:
List<Line> Page(List<Line> items) {
return items.Where(i => i.Amount > 0).OrderBy(i => i.Sku).Skip(20).Take(10);
}Find one element, with a safe fallback:
string SkuOrNone(List<Line> lines, int amount) {
var hit = lines.FirstOrDefault(l => l.Amount == amount); // null when there is none
if (hit == null) { return "none"; }
return hit.Sku;
}How do I reshape each element? — Select#
Select reshapes each element. Project into a class you declared (the C# spelling, new U { … }), or into a
scalar — which gives you a plain List<string> / List<int> of the selected values:
class LineDto { public string Code; public int Doubled; }
List<LineDto> ToDtos(List<Line> lines) {
return lines.Select(x => new LineDto { Code = x.Sku, Doubled = x.Amount * 2 });
}
List<string> SkusByAmount(List<Line> lines) {
return lines.OrderByDescending(l => l.Amount).Select(l => l.Sku); // → a List<string>
}
int DistinctAmounts(List<Line> lines) {
return lines.Select(l => l.Amount).Distinct().Count(); // dedupe the projected values
}A HashSet<T> is a source too — filter and project a set exactly as you would a list:
int BigOnesInSet(HashSet<Line> set) {
return set.Where(x => x.Amount >= 20).Count();
}Can I query a string[] or an int[]?#
T does not have to be a class. A string[], an int[], a List<decimal> — any sequence of scalars answers the
same verbs, and the range variable is simply the value:
int NamesStartingWithA(string[] names) {
return names.Where(n => n.StartsWith("a")).Count();
}
bool AllNamed(string[] names) {
return names.All(n => n != ""); // true when there are no names at all, exactly as in C#
}
string FirstAlphabetically(string[] names) {
return names.OrderBy(k => k).First(n => n != "");
}
List<int> Lengths(string[] names) {
return names.Select(n => n.Length); // → a List<int>
}
int TotalAndLargest(int[] amounts) {
return amounts.Sum(a => a) + amounts.Max(a => a);
}There is no separate scalar dialect to learn: Where, Select, OrderBy, GroupBy, Any/All, the aggregates,
the set operators and the First/Single family all read a scalar element exactly as they read a row. The one verb that
cannot apply is Include — it loads a related row, and a string has no relations, so it is refused by name.
Rows you already fetched are a source too#
Once a query has returned, its rows are just a collection you hold — so a second question about them is asked the same way, with no "copy it into a list first" step:
bool AnyLarge(List<Line> lines) {
var positive = lines.Where(l => l.Amount > 0).ToList();
return positive.Any(l => l.Amount >= 100); // over the rows already in hand — no second fetch
}This is the same rule as everywhere else on this page — what you query decides where it runs. The first chain
above touched a list you built; had it named an entity, that chain would have run in the database, and only the rows
it returned would be here to ask about. The live var a screen binds behaves identically: it holds
rows, so .Count, .Any(…), .Where(…), indexing and foreach all read it directly.
Where in the list is it? — IndexOf and FindIndex#
The LINQ verbs above answer which element; these answer where it sits. They are C#'s own, they count from
zero, and they answer -1 when there is no match — never a fault, so >= 0 is the guard you write.
There are four, and they are two pairs. One axis is what you are holding — the element itself, or a predicate that describes it. The other is which end you want:
| the FIRST match | the LAST match | |
|---|---|---|
| you HOLD it | IndexOf(item) | LastIndexOf(item) |
| you DESCRIBE it | FindIndex(x => …) | FindLastIndex(x => …) |
int WhereIsIt(List<Line> lines, Line one) {
return lines.IndexOf(one); // you HOLD the element — compare it
}
int WhereIsTheSku(List<Line> lines, string sku) {
return lines.FindIndex(l => l.Sku == sku); // you can DESCRIBE it — run a predicate
}
bool IsFirst(List<Line> lines, string sku) {
return lines.FindIndex(l => l.Sku == sku) == 0; // a miss is -1, so this is false rather than a fault
}
int WhereIsTheLastSku(List<Line> lines, string sku) {
return lines.FindLastIndex(l => l.Sku == sku); // the LAST one that matches — "the most recent entry for this sku"
}Which one you reach for is decided by what you are holding, and nothing else. IndexOf(item) takes the element
itself; FindIndex(x => …) takes a predicate, which is what you want whenever the thing you are looking for is
described rather than held — a value typed by a user, an id off the URL, a name off a form.
The two agree by construction: IndexOf compares elements the same way == does, so a row found by one is found by
the other at the same position.
⚠ A list of ENTITY ROWS is not a special case, and this is the one people talk themselves out of. == on two
entity references compares them by row identity, so IndexOf(row) finds the row even when the
list came from one query and the row from another — the two references are the same row. Writing
FindIndex(x => x.Id == row.Id) for that is not wrong, it is the same answer one lambda longer.
A position is what a reorder is written in terms of, and that is the commonest use — where a row is, what is above it, whether it can move:
Line Previous(List<Line> ordered, string sku) {
var at = ordered.FindIndex(l => l.Sku == sku);
if (at <= 0) { return null; } // not found (-1), or already first
return ordered[at - 1];
}FindIndex searches the list in order and stops at the first match, so a predicate that matches twice answers the
earlier position. FindLastIndex searches from the END and stops at the last, which is what you want for "the most
recent one", "the row below this one" and any reorder that walks downwards:
class Ends { public int First; public int Last; }
Ends BothEnds(List<Line> lines, string sku) {
return new Ends {
First = lines.FindIndex(l => l.Sku == sku), // 0, if the sku is first and also appears later
Last = lines.FindLastIndex(l => l.Sku == sku), // …and the position of its LAST appearance
};
}Both answer a position in the list you asked — FindLastIndex is a backwards scan, not a search over a reversed
copy, so the index it hands back indexes the original. And both short-circuit, from their own end: a predicate
with a side effect sees the elements up to the hit, and for FindLastIndex that means the tail.
How do I total per group, and then filter the groups?#
GroupBy(x => key) buckets the elements by a key; the Select that follows reduces each bucket, reading the key as
g.Key and folding the group's elements with g.Count() / g.Sum(…) / g.Min(…) / g.Max(…) /
g.Average(…). Project each group into a class:
class GroupStat { public string Key; public int Count; public int Total; public int MaxAmount; }
List<GroupStat> PerSku(List<Line> lines) {
return lines.GroupBy(x => x.Sku)
.Select(g => new GroupStat {
Key = g.Key,
Count = g.Count(),
Total = g.Sum(e => e.Amount),
MaxAmount = g.Max(e => e.Amount) })
.OrderBy(s => s.Key);
}A Where before the GroupBy filters the elements that get grouped. A Where after the projection filters
the groups — it is SQL's HAVING, written as an ordinary predicate over the projected shape, and it composes with
OrderBy and Take like anything else:
List<GroupStat> TopSku(List<Line> lines) {
return lines.Where(x => x.Amount >= 10) // filters ELEMENTS, before grouping
.GroupBy(x => x.Sku)
.Select(g => new GroupStat {
Key = g.Key,
Count = g.Count(),
Total = g.Sum(e => e.Amount),
MaxAmount = g.Max(e => e.Amount) })
.Where(s => s.Total >= 35) // filters GROUPS — this is HAVING
.OrderByDescending(s => s.Total)
.Take(1);
}You never write the word having: which side of the GroupBy a Where sits on says what it filters, and that is the
whole rule.
Flatten, match, pair, fold and index — SelectMany, Join, Zip, Aggregate, ToDictionary#
The verbs that take lambdas of their own work over a list in hand exactly as in C#, and a chain continues over what they answer:
SelectMany(x => seq)flattens one sequence per element into one list — in source order, then each element's own order. The selector may answer a list, an array or a list literal (x => [x, x * 10]). The two-selector formSelectMany(x => seq, (x, item) => …)sees each element and each of its items;(x, i) => …binds the position.Join(ys, x => key, y => key, (x, y) => …)pairs the elements whose keys are equal — in the outer list's order, then the inner list's. A null key matches nothing. The two keys must be the same type.LeftJoin(…)isJointhat keeps an outer element with no match, pairing it with C#'sdefault:0for a number,falsefor a bool, andnullfor anything else — so the result selector's second parameter is nullable there, and the compiler asks you to guard it.Zip(ys, (a, b) => …)pairs elements by position and stops at the shorter list.Aggregate((acc, x) => …)folds the list into one value, starting from the first element — so over an empty list it throws, as C# does.Aggregate(seed, (acc, x) => …)starts from the seed (and never throws), and a third argumentacc => …maps the final value.ToDictionary(x => key)/ToDictionary(x => key, x => value)builds aDictionary<K, V>. A duplicate or a null key throws, with the same messageAddgives.
class Crew { public string Name; public List<string> Skills; }
List<string> EverySkill(List<Crew> crews) {
return crews.SelectMany(c => c.Skills, (c, s) => c.Name + ": " + s).ToList();
}
List<string> Assignments(List<string> tasks, List<Crew> crews) {
// the task keeps its place even when nobody is named for it — `c` is null there
return tasks.LeftJoin(crews, t => t, c => c.Name, (t, c) => c == null ? t + " (open)" : t + " → " + c.Name).ToList();
}
List<int> Totals(List<int> planned, List<int> extra) {
return planned.Zip(extra, (p, e) => p + e).ToList();
}
int Longest(List<Crew> crews) {
return crews.Aggregate(0, (widest, c) => c.Skills.Count > widest ? c.Skills.Count : widest);
}
int SkillCountOf(List<Crew> crews, string name) {
var byName = crews.ToDictionary(c => c.Name, c => c.Skills.Count);
return byName.ContainsKey(name) ? byName[name] : 0;
}Keep one of each — DistinctBy#
DistinctBy(x => key) keeps the first element of each key and drops the rest, in the order the list holds them —
.NET's own verb. The key is compared the way Distinct compares (a scalar by value, a class value by identity), and a
null key is a key like any other: the first element whose key is null is kept. Distinct() with no key is the
whole-element form.
List<Line> FirstOfEachSku(List<Line> lines) {
return lines.DistinctBy(l => l.Sku).ToList();
}
int SkusWithAPositiveFirstLine(List<Line> lines) {
return lines.DistinctBy(l => l.Sku).Where(l => l.Amount > 0).Count();
}The overload that takes an equality comparer is not supported. Over rows read from the data store DistinctBy is refused
and points at ….ToList() first — the database has no "first" without an order, and C# on a database refuses it too.
Set operators — combine two lists#
Union, Concat, Intersect, and Except combine two local lists of the same class, exactly as in C#: Union is
the distinct elements of both, Concat keeps every element (duplicates and all), Intersect keeps the elements in
both, and Except keeps the left elements that are not in the right. The operand can be a plain list or its own filtered
chain:
List<Line> Both(List<Line> a, List<Line> b) {
return a.Union(b.Where(l => l.Amount > 0)).ToList();
}De-duplication (Union/Intersect/Except) uses the same equality as Distinct: a class value is compared by
identity (the same instance), a scalar by value — the C# default. Two lists that share an instance de-dupe it;
distinct instances stay distinct.
Either side may be reshaped first, and the chain goes on after the operator — Select before Concat combines
the projected values, and a Where or Count after it runs over the combined list:
int BigOnes(List<Line> a, List<int> extraAmounts) {
return a.Select(l => l.Amount).Concat(extraAmounts).Where(n => n > 10).Count();
}What else does System.Linq give me over a list in hand?#
The rest of C#'s Enumerable works over a list you hold, with C#'s answers:
| verb | answers |
|---|---|
Prepend(x) · Append(x) | one element before / after the rest |
TakeLast(n) · SkipLast(n) | the last n elements / all but them |
MinBy(x => key) · MaxBy(x => key) | the element with the smallest / largest key — not the key itself, which is what Min/Max answer |
ExceptBy(keys, x => key) · IntersectBy(keys, x => key) | the elements whose key is not / is in a sequence of keys |
UnionBy(other, x => key) | both lists, keeping the first element of each key |
GroupJoin(inner, o => key, i => key, (o, matches) => …) | each outer element with all its matches — none is an empty list, not a missing row |
RightJoin(inner, o => key, i => key, (o, i) => …) | LeftJoin from the other side: every inner element, and null for an outer that has no match |
Order() · OrderDescending() | sorted by the elements themselves |
Shuffle() | the same elements in a random order (.NET 10) |
AsEnumerable() | the sequence itself — a no-op here, kept so C# you paste compiles |
LongCount() | Count() as a long |
SequenceEqual(other) | whether the two hold equal elements in the same order |
Over rows read from the data store each of these but LongCount refuses by name and points at ….ToList() first,
because none has a SQL form.
[Test]
void The_rest_of_enumerable_answers_what_csharp_answers() {
var n = new List<int> { 3, 1, 4, 1, 5 };
Assert.Equal(new List<int> { 9, 3, 1, 4, 1, 5 }, n.Prepend(9).ToList());
Assert.Equal(new List<int> { 3, 1, 4 }, n.SkipLast(2).ToList());
Assert.Equal(new List<int> { 1, 5 }, n.TakeLast(2).ToList());
Assert.Equal(new List<int> { 5, 4, 3, 1, 1 }, n.OrderDescending().ToList());
Assert.Equal(5L, n.LongCount());
Assert.Equal(5, n.AsEnumerable().Count());
Assert.Equal(5, n.Shuffle().Count());
Assert.True(n.SequenceEqual(new List<int> { 3, 1, 4, 1, 5 }));
Assert.False(n.SequenceEqual(new List<int> { 1, 3, 4, 1, 5 }));
var lines = new List<Line> { new Line { Sku = "A", Amount = 3 }, new Line { Sku = "B", Amount = 9 }, new Line { Sku = "C", Amount = 1 } };
Assert.Equal("C", lines.MinBy(l => l.Amount).Sku);
Assert.Equal("B", lines.MaxBy(l => l.Amount).Sku);
var gone = new List<string> { "B" };
Assert.Equal(new List<string> { "A", "C" }, lines.ExceptBy(gone, l => l.Sku).Select(l => l.Sku).ToList());
Assert.Equal(new List<string> { "B" }, lines.IntersectBy(gone, l => l.Sku).Select(l => l.Sku).ToList());
var more = new List<Line> { new Line { Sku = "B", Amount = 0 }, new Line { Sku = "D", Amount = 2 } };
Assert.Equal(new List<string> { "A", "B", "C", "D" }, lines.UnionBy(more, l => l.Sku).Select(l => l.Sku).ToList());
var skus = new List<string> { "A", "B", "Z" };
Assert.Equal(new List<string> { "A:0", "B:1", "Z:0" },
skus.GroupJoin(more, s => s, l => l.Sku, (s, matches) => s + ":" + matches.Count()).ToList());
Assert.Equal(new List<string> { "B=B", "?=D" },
skus.RightJoin(more, s => s, l => l.Sku, (s, l) => (s ?? "?") + "=" + l.Sku).ToList());
}Where does this run, and what is refused in memory?#
The distinction matters, and it is decided by what you query, not by which verb you use. Naming an entity
(Order.Where(…)) compiles the predicate into the database query, so the rows you did not ask for are never
fetched — and the security rules are part of that query. Querying a List<T> you built
in code evaluates the predicate over the elements you already have, in the running function. Same spelling, and
that is the point: you do not learn two query languages. But a list of ten million elements is ten million elements
in memory, and no security rule applies to values you constructed yourself.
A grouping can answer one value per group, too. list.GroupBy(k).Select(g => g.Count()) (or g => g.Key, or
any aggregate) answers a plain list of those values, as in C#. And the groups themselves are values —
IGrouping<K, T>, with ToLookup holding them by key.
List<int> CountPerSku(List<Line> lines) {
return lines.GroupBy(l => l.Sku).Select(g => g.Count()).ToList();
}What is not available on a local list. The data-store-only operations are meaningless over values you already hold,
so they report a pointed error rather than pretending: full-text search (Matches/TextScore), vector Similarity,
Traverse, and Include. Use those over your entities, where they lower to the database. (Include is refused over a
scalar element for a second reason as well: a string has nothing related to load.)
Zip with no result selector pairs the elements as C# does, and TakeWhile/SkipWhile run over a list in hand.
Zip, TakeWhile, SkipWhile, Aggregate, ToDictionary and DistinctBy exist only over values in hand — over rows
read from the data store they refuse and point at ….ToList() first; SelectMany and Join/LeftJoin also lower to the database
over entities, and the set operators work over entities as well.
See also#
- Where / Single / Count — the same verbs over your entities, where they lower to the database
- Skip / Take (paging) —
Skip/Takeover your entities (the database form) - Union / Concat / Intersect / Except —
Union/Concat/Intersect/Except(the entity-query form; the spellings are identical) - Join / LeftJoin / SelectMany —
SelectMany,JoinandLeftJoinover your entities, where they become SQL joins - Comparing entity rows — why
IndexOf/Contains/Removeidentify an entity row correctly, whatever query it came from - class methods — the
classtypes these lists hold