Osy#betaRun it on Osyrin: osyrin.com

Reference / Query

LINQ over a local list

list.Where(x => …) · .Select(x => new T { … }) · .GroupBy(x => key) · .First(…) · .Any(…) · .Count(…) · .Sum(x => …) · .OrderBy(…) · .Take(n) · .IndexOf(item) · .FindIndex(x => …) · .FindLastIndex(x => …) · .SelectMany(x => …) · .Join(ys, …) · .Zip(ys, …) · .Aggregate(…) · .ToDictionary(…) · .DistinctBy(x => key)

Query a local `List<T>`, `HashSet<T>` or `T[]` — of your own `class` values OR of plain scalars like `string[]` and `int[]` — with the same LINQ verbs you use over data: `Where`, `Select` projections, `GroupBy` with per-group aggregates, `First`/`FirstOrDefault`/`Single`, `Any`, `Count`, `Sum`/`Average`/`Min`/`Max`, `Distinct`, `Skip`, `OrderBy`, `Take` — evaluated in memory. The data-store-only operations (full-text, similarity) don't apply to a plain list.

stable18 examples compiled by CIlinqquerycollectionslist

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 none

The 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 matchthe LAST match
you HOLD itIndexOf(item)LastIndexOf(item)
you DESCRIBE itFindIndex(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 form SelectMany(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(…) is Join that keeps an outer element with no match, pairing it with C#'s default: 0 for a number, false for a bool, and null for 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 argument acc => … maps the final value.
  • ToDictionary(x => key) / ToDictionary(x => key, x => value) builds a Dictionary<K, V>. A duplicate or a null key throws, with the same message Add gives.
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:

verbanswers
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#

Related

Skip / Take (paging)

Page a query with Skip(n) (OFFSET) and Take(m) (LIMIT). The count can be a compile-time constant OR a runtime integer —…

Union / Concat / Intersect / Except

Combines two row-queries over the same entity with SQL set semantics: Union dedups, Concat keeps duplicates (UNION…

class methods

Behaviour attached to a class — a method with a receiver, called as value.Method(). Classes are in-memory values, so a…

Sum / Average / Min / Max / Count

Fold rows down to a single number — a query or a `List<T>` you already hold. The one thing to know before you use them:…