Querying Sequences
These methods ask questions of a sequence and pull pieces out of it. They fill gaps in LINQ: "is it empty?", "does it hold all of these?", "take everything up to the END line", "join both sides and keep the rows that match nothing". Reach for them when the LINQ version needs a negation, a helper HashSet, or a hand-written loop.
All methods live in the System.Collections.Generic namespace (the IQueryable form of WhereIf lives in System.Linq), so you do not need a using directive.
At a glance
| Method | What it does |
|---|---|
IsEmpty | true if the sequence has no items. |
None | true if the sequence has no items, or no item matches a condition. The opposite of Any. |
MatchAny | true if the sequence contains at least one of the given values. |
MatchAll | true if both sides hold the same items the same number of times, in any order. |
AllIn | true if every item of the sequence is one of the given values. |
ContainsAll | true if the sequence contains every one of the given values. |
WhereIf | Applies a Where filter only when a condition is true. Also on IQueryable<T>. |
Partition | Splits a sequence in one pass into the matching and the non-matching items. |
TakeUntil | Yields items up to and including the first match. |
SkipUntil | Skips items until the first match, then yields the match and the rest. |
Union(item) | Set union of a sequence and one extra item. |
Interleave | Merges sequences round-robin: first of each, then second of each, and so on. |
FullOuterJoin | Joins two sequences by key and keeps the unmatched rows of both sides. |
Flatten | Turns a sequence of sequences into one sequence (one level). |
WithIndex | Pairs every item with its zero-based position. |
Pairwise | Pairs or combines each item with the one before it. |
Split(size) | Cuts an array into chunks of at most size items. |
IsSorted | true if the items are in non-decreasing order. |
IsSortedBy | true if a key of the items is in non-decreasing order. |
MinMax | Finds the smallest and the largest item in one pass. |
InRandomOrder | Returns a shuffled copy, repeatable with a seeded Random. |
Frequencies | Counts how often each item, or each key, occurs. |
JoinWith | Joins items into one string with invariant formatting. |
To render a sequence as an HTML <table>, see ToHtmlTable on the Console Output page.
Common rules
- A
nullsequence, predicate, selector or target throwsArgumentNullExceptionwith the parameter name. The check runs when you call the method, not later when you enumerate the result. - Methods that return
IEnumerable<T>are lazy: they read the source only when you enumerate the result. The notes on each method say when something is buffered. - Wherever a method takes a
comparer,nullmeans the default equality or ordering.
See Conventions for the rules that apply across the whole library.
Checking for items
IsEmpty
IsEmpty() returns true when the sequence has no items. It reads better than !items.Any() in a condition.
List<string> validationErrors = new List<string>();
bool canSave = validationErrors.IsEmpty(); // → true
validationErrors.Add("Email is required.");
canSave = validationErrors.IsEmpty(); // → false
IsEmpty reads at most one item, so it is cheap even on a lazy query.
None
None() is the same check as IsEmpty(). None(predicate) returns true when no item matches the predicate. It is the readable opposite of Any(predicate), so you avoid a leading !.
public sealed record Order(int Id, string Status, bool IsOverdue);
Order[] orders =
{
new Order(1, "Paid", false),
new Order(2, "Open", true),
new Order(3, "Open", false),
};
bool nothingOverdue = orders.None(o => o.IsOverdue); // → false
bool nothingCancelled = orders.None(o => o.Status == "Cancelled"); // → true
bool noOrders = orders.None(); // → false
bool emptyHasNoMatch = Array.Empty<Order>().None(o => o.IsOverdue); // → true
None(predicate) stops at the first match. An empty sequence returns true.
Matching against a set of values
These four methods compare a sequence with a list of values. Each has two forms:
Method(params T[] target), for values written inline.Method(IEnumerable<T> target, IEqualityComparer<T>? comparer), for a list you already have or a custom comparison.
| Method | Question it answers | Counts duplicates? | Empty source | Empty target |
|---|---|---|---|---|
MatchAny | Do the two sides share at least one item? | No | false | false |
MatchAll | Same items, same counts, any order? | Yes | true only if the target is empty too | true only if the source is empty too |
AllIn | Is every source item in the target? | No | true | false unless the source is empty |
ContainsAll | Is every target item in the source? | No | false unless the target is empty | true |
The params form only accepts an array. To pass a List<T> or another sequence, use the two-argument form and pass null as the comparer for default equality: tags.MatchAny(blockedTags, null).
MatchAny
MatchAny returns true when at least one item of the sequence also appears in the target. Use it to check tags, roles or flags against a short list.
string[] ticketTags = { "billing", "urgent", "customer" };
bool needsTriage = ticketTags.MatchAny("urgent", "outage"); // → true
bool isSecurity = ticketTags.MatchAny("security", "cve"); // → false
bool ignoringCase = ticketTags.MatchAny(new[] { "URGENT" }, StringComparer.OrdinalIgnoreCase); // → true
The target may contain null; a null on both sides counts as a match. An empty target returns false.
MatchAll
MatchAll returns true when both sides hold the same items, each the same number of times, in any order. It is a multiset comparison: [1, 2, 2] matches [2, 1, 2] but not [1, 2].
string[] orderedSkus = { "SKU-1", "SKU-2", "SKU-2" };
string[] pickedSkus = { "SKU-2", "SKU-1", "SKU-2" };
bool complete = pickedSkus.MatchAll(orderedSkus); // → true
bool missingOne = pickedSkus.MatchAll("SKU-1", "SKU-2"); // → false (the SKU-2 counts differ)
bool ignoringCase = new[] { "a", "B" }.MatchAll(new[] { "b", "A" }, StringComparer.OrdinalIgnoreCase); // → true
null items are counted like any other value: [null, "a", null] matches ["a", null, null]. Two empty sequences match.
AllIn
AllIn returns true when every item of the sequence is one of the target values. Use it to check that a request only uses allowed values.
string[] requestedScopes = { "orders:read", "users:read" };
bool allowed = requestedScopes.AllIn("orders:read", "orders:write", "users:read"); // → true
bool tooMuch = new[] { "orders:read", "admin" }.AllIn("orders:read", "users:read"); // → false
bool nothingRequested = Array.Empty<string>().AllIn("orders:read"); // → true
An empty source returns true, the same as Enumerable.All. Check IsEmpty() first if "nothing requested" must be rejected.
ContainsAll
ContainsAll returns true when the sequence contains every one of the target values. It is AllIn with the two sides swapped.
string[] grantedRoles = { "admin", "billing", "support" };
bool canRefund = grantedRoles.ContainsAll("billing", "support"); // → true
bool canDeploy = grantedRoles.ContainsAll("admin", "devops"); // → false
bool ignoringCase = grantedRoles.ContainsAll(new[] { "BILLING" }, StringComparer.OrdinalIgnoreCase); // → true
An empty target returns true: every one of zero required values is present.
Filtering and splitting
WhereIf
WhereIf(condition, predicate) applies Where(predicate) only when condition is true. When it is false, you get the original sequence back, the same instance. It removes the if (x) query = query.Where(...) steps from search code with optional filters.
public sealed record User(string Name, bool IsActive, string Country);
User[] users =
{
new User("Ahmad", true, "JO"),
new User("Lina", true, "DE"),
new User("Omar", false, "JO"),
};
bool onlyActive = true;
string? country = null;
IEnumerable<User> result = users
.WhereIf(onlyActive, u => u.IsActive)
.WhereIf(country is not null, u => u.Country == country);
string[] names = result.Select(u => u.Name).ToArray(); // → ["Ahmad", "Lina"]
There is a second overload on IQueryable<T> (in System.Linq). It takes an Expression<Func<T, bool>>, so Entity Framework and other LINQ providers still translate the filter to SQL.
// db is an Entity Framework DbContext whose Order entity has CustomerId and Status;
// customerId and onlyOpen come from the request.
IQueryable<Order> query = db.Orders
.WhereIf(customerId is not null, o => o.CustomerId == customerId)
.WhereIf(onlyOpen, o => o.Status == "Open");
The predicate is checked for null even when condition is false.
Partition
Partition(predicate) reads the sequence once and returns two lists: the items that match and the items that do not. Both keep the original order. Deconstruct the result into two variables.
public sealed record Invoice(string Number, decimal Total, bool IsPaid);
Invoice[] invoices =
{
new Invoice("INV-1", 120m, true),
new Invoice("INV-2", 80m, false),
new Invoice("INV-3", 45m, true),
};
(IReadOnlyList<Invoice> paid, IReadOnlyList<Invoice> unpaid) = invoices.Partition(i => i.IsPaid);
string[] paidNumbers = paid.Select(i => i.Number).ToArray(); // → ["INV-1", "INV-3"]
string[] unpaidNumbers = unpaid.Select(i => i.Number).ToArray(); // → ["INV-2"]
If you keep the result in one variable, the tuple elements are named Matched and Unmatched. Partition is not lazy: it reads the whole source when you call it and calls the predicate once per item. An empty source gives two empty lists.
TakeUntil
TakeUntil(predicate) yields items up to and including the first one that matches, then stops. TakeWhile(x => !predicate(x)) would drop that matching item; TakeUntil keeps it.
string[] lines = { "HEADER", "BEGIN", "row 1", "row 2", "END", "footer" };
IEnumerable<string> throughEnd = lines.TakeUntil(line => line == "END");
// → ["HEADER", "BEGIN", "row 1", "row 2", "END"]
IEnumerable<string> noMarker = lines.TakeUntil(line => line == "EOF");
// → ["HEADER", "BEGIN", "row 1", "row 2", "END", "footer"]
If nothing matches, you get the whole sequence. TakeUntil does not read past the match, so it is safe on an endless or expensive source.
SkipUntil
SkipUntil(predicate) skips items until the first one that matches, then yields that item and everything after it. Later items are not tested again.
string[] lines = { "HEADER", "BEGIN", "row 1", "row 2", "END", "footer" };
IEnumerable<string> fromBegin = lines.SkipUntil(line => line == "BEGIN");
// → ["BEGIN", "row 1", "row 2", "END", "footer"]
IEnumerable<string> body = lines
.SkipUntil(line => line == "BEGIN")
.TakeUntil(line => line == "END");
// → ["BEGIN", "row 1", "row 2", "END"]
If nothing matches, the result is empty.
Combining sequences
Union(item)
Union(item) is Enumerable.Union with a single item instead of a second sequence. The result has no duplicates, including duplicates that were already in the source.
int[] watcherIds = { 7, 12, 12 };
IEnumerable<int> withAuthor = watcherIds.Union(3); // → [7, 12, 3]
IEnumerable<int> alreadyThere = watcherIds.Union(12); // → [7, 12]
The result is lazy, like Enumerable.Union. Items keep first-seen order, and the new item comes last if it was not already there.
Interleave
Interleave(params others) merges sequences round-robin: the first item of each sequence, then the second of each, and so on. When one sequence runs out it drops out, and the others continue.
string[] priority = { "P1", "P2", "P3" };
string[] standard = { "S1" };
string[] bulk = { "B1", "B2" };
IEnumerable<string> schedule = priority.Interleave(standard, bulk);
// → ["P1", "S1", "B1", "P2", "B2", "P3"]
With no others, you get the items of the source unchanged. A null entry in others throws ArgumentNullException (parameter others) at call time. Every enumerator is disposed, also when you stop early, for example with Take.
FullOuterJoin
FullOuterJoin joins two sequences by key and keeps every row of both sides. An outer row with no match gets default for the inner side. An inner row with no match gets default for the outer side. LINQ has Join (an inner join) and GroupJoin, but no full outer join.
The parameters are (inner, outerKeySelector, innerKeySelector, resultSelector, comparer = null). The result selector receives (outer or default, inner or default, key).
public sealed record StockLine(string Sku, int OnHand);
public sealed record OrderLine(string Sku, int Ordered);
StockLine[] stock = { new StockLine("A-100", 5), new StockLine("B-200", 2) };
OrderLine[] orders = { new OrderLine("B-200", 1), new OrderLine("C-300", 7) };
List<(string Sku, int OnHand, int Ordered)> report = stock
.FullOuterJoin(
orders,
s => s.Sku,
o => o.Sku,
(s, o, sku) => (sku, s?.OnHand ?? 0, o?.Ordered ?? 0))
.ToList();
// → [("A-100", 5, 0), ("B-200", 2, 1), ("C-300", 0, 7)]
Result order:
- Outer rows in outer order. An outer row that matches several inner rows appears once per match, in inner order.
- Then the inner rows that matched nothing, in inner order.
- Keys may be
null; anullkey matches anullkey on the other side. - Pass a comparer to change key equality, for example
StringComparer.OrdinalIgnoreCase. - The inner sequence is read into memory when enumeration starts. The outer sequence is streamed.
- For value types, the missing side is the type's default (
0forint), notnull. Use?.and??with reference types, as in the example.
Flatten
Flatten() turns a sequence of sequences into one sequence. It does what SelectMany(x => x) does, with a name that says so. It flattens one level only.
List<List<string>> batches = new List<List<string>>
{
new List<string> { "ahmad@example.com", "lina@example.com" },
new List<string>(),
new List<string> { "omar@example.com" },
};
IEnumerable<string> recipients = batches.Flatten();
// → ["ahmad@example.com", "lina@example.com", "omar@example.com"]
A null inner sequence is skipped, not an error. The item type is inferred, so List<List<string>> and int[][] both work.
Walking and chunking
WithIndex
WithIndex() pairs every item with its zero-based position as a tuple (Item, Index). Deconstruct it in a foreach when the loop needs the index.
string[] steps = { "Validate cart", "Charge card", "Ship order" };
List<string> numbered = new List<string>();
foreach ((string step, int index) in steps.WithIndex())
{
numbered.Add($"{index + 1}. {step}");
}
// numbered → ["1. Validate cart", "2. Charge card", "3. Ship order"]
Index()?.NET 9 added Enumerable.Index(), which returns (int Index, T Item) with the index first. WithIndex uses a different name so the two never clash, and it also works on the netstandard2.1 build.
WithIndex is lazy. See foreach and Deconstruction for other loop helpers.
Pairwise
Pairwise() returns each item paired with the one before it: [a, b, c] gives (a, b) and (b, c). Pairwise(resultSelector) combines each pair directly, for example to compute differences between readings.
decimal[] monthlyRevenue = { 1200m, 1500m, 1350m, 1800m };
IEnumerable<decimal> changes = monthlyRevenue.Pairwise((before, after) => after - before);
// → [300m, -150m, 450m]
List<(decimal Previous, decimal Current)> firstTwo = monthlyRevenue.Pairwise().Take(2).ToList();
// → [(1200m, 1500m), (1500m, 1350m)]
A sequence with fewer than two items gives an empty result. Pairwise is lazy and reads the source once.
Split
Split(size) cuts an array into chunks of at most size items. The last chunk may be smaller. Each chunk is a copy, so changing the array later does not change chunks you already have.
int[] customerIds = { 101, 102, 103, 104, 105 };
List<int[]> batches = customerIds.Split(2).Select(batch => batch.ToArray()).ToList();
// → [[101, 102], [103, 104], [105]]
An empty array gives no chunks. A size of zero or less throws ArgumentOutOfRangeException (parameter size) at call time.
Split only works on arrays. On .NET 6 and later, Enumerable.Chunk(size) does the same for any sequence. Split also works on the netstandard2.1 build.
Order and extremes
IsSorted
IsSorted() returns true when no item is smaller than the one before it (non-decreasing order). Equal neighbours are allowed. IsSorted(comparer) uses the ordering you pass. Empty and single-item sequences are sorted.
int[] invoiceNumbers = { 1001, 1002, 1002, 1005 };
bool inOrder = invoiceNumbers.IsSorted(); // → true
int[] mixedUp = { 1001, 1003, 1002 };
bool mixedUpInOrder = mixedUp.IsSorted(); // → false
string[] codes = { "a", "B", "c" };
bool ignoringCase = codes.IsSorted(StringComparer.OrdinalIgnoreCase); // → true
The check stops at the first item that is out of order.
With no comparer, strings are compared with Comparer<string>.Default, which uses the current culture. Pass StringComparer.Ordinal or StringComparer.OrdinalIgnoreCase when the result must be the same on every machine. The same applies to MinMax.
IsSortedBy
IsSortedBy(keySelector) checks the order of a key instead of the items themselves. IsSortedBy(keySelector, comparer) also takes an ordering for the key. To check for descending order, pass a reversed comparer.
public sealed record AuditEvent(string Action, DateTime OccurredAt);
AuditEvent[] events =
{
new AuditEvent("login", new DateTime(2024, 5, 1, 9, 0, 0)),
new AuditEvent("view", new DateTime(2024, 5, 1, 9, 5, 0)),
new AuditEvent("logout", new DateTime(2024, 5, 1, 9, 3, 0)),
};
bool chronological = events.IsSortedBy(e => e.OccurredAt); // → false
decimal[] totals = { 300m, 120m, 120m, 45m };
IComparer<decimal> descending = Comparer<decimal>.Create((x, y) => y.CompareTo(x));
bool largestFirst = totals.IsSortedBy(total => total, descending); // → true
MinMax
MinMax() finds the smallest and the largest item in a single pass and returns them as a tuple (Min, Max). Calling Min() and Max() separately reads the sequence twice. MinMax(comparer) uses the ordering you pass.
decimal[] prices = { 19.99m, 4.50m, 120.00m, 35.25m };
(decimal low, decimal high) = prices.MinMax(); // → (4.50m, 120.00m)
string[] skus = { "B-20", "A-1", "C-300" };
IComparer<string> byLength = Comparer<string>.Create((x, y) => x.Length - y.Length);
(string shortest, string longest) = skus.MinMax(byLength); // → ("A-1", "C-300")
A single-item sequence returns that item as both Min and Max. When several items tie, the first one wins.
An empty sequence throws InvalidOperationException ("Sequence contains no elements."). MinMax does not skip null items the way Enumerable.Min does for reference types: the default comparer orders null before every other value.
InRandomOrder
InRandomOrder(random) returns the items in a random order. It shuffles a copy (Fisher–Yates), so the source is not changed. The same seed gives the same order, which keeps tests repeatable. It takes a Random so you can seed it, and its name does not clash with the Shuffle method of .NET 10.
string[] questions = Enumerable.Range(1, 20).Select(n => $"Q{n}").ToArray();
List<string> quizA = questions.InRandomOrder(new Random(2024)).Take(5).ToList();
List<string> quizB = questions.InRandomOrder(new Random(2024)).Take(5).ToList();
bool sameQuiz = quizA.SequenceEqual(quizB); // → true
string firstQuestion = questions[0]; // → "Q1" (the source is unchanged)
- The copy is made when enumeration starts, not when you call the method. Each new enumeration shuffles again and moves the
Randomforward, so callToList()if you need one fixed order. Randomis not cryptographically secure. Do not use it to pick winners, tokens or anything an attacker could gain from predicting.- To shuffle a list in place, use
random.Shuffle(list)from the Random page.
Counting and joining
Frequencies
Frequencies() counts how many times each item occurs and returns a Dictionary<T, int>. Frequencies(keySelector, comparer = null) counts by a key instead. Keys keep first-seen order.
public sealed record Order(int Id, string Status, bool IsOverdue);
Dictionary<char, int> letters = "hello".Frequencies();
// → { ['h'] = 1, ['e'] = 1, ['l'] = 2, ['o'] = 1 }
Order[] orders =
{
new Order(1, "Open", false),
new Order(2, "Shipped", false),
new Order(3, "Open", true),
};
Dictionary<string, int> byStatus = orders.Frequencies(o => o.Status);
// → { ["Open"] = 2, ["Shipped"] = 1 }
Dictionary<string, int> tags = new[] { "Urgent", "urgent", "bug" }
.Frequencies(tag => tag, StringComparer.OrdinalIgnoreCase);
// → { ["Urgent"] = 2, ["bug"] = 1 }
With a comparer, equivalent keys are merged under the first spelling seen, and the returned dictionary uses that comparer for lookups. An empty sequence gives an empty dictionary.
Keys must not be null; a null key throws ArgumentNullException from the dictionary. On .NET 9 and later, Enumerable.CountBy returns similar counts as a lazy sequence of KeyValuePair values; Frequencies gives you a dictionary you can look up.
JoinWith
JoinWith(separator) joins the items into one string with the separator between them. There is a string separator overload and a char separator overload. Values are formatted with the invariant culture, so numbers and dates look the same on every machine. null items become empty text.
decimal[] amounts = { 1.5m, 20m, 3.25m };
string amountList = amounts.JoinWith(", "); // → "1.5, 20, 3.25"
int[] orderIds = { 3, 7, 12 };
string idList = orderIds.JoinWith(','); // → "3,7,12"
object?[] cells = { "INV-7", null, 42 };
string row = cells.JoinWith(" | "); // → "INV-7 | | 42"
An empty sequence gives "". A null string separator throws ArgumentNullException (parameter separator).
string.Join formats with the current culture, so 1.5 becomes "1,5" on a German machine. JoinWith always writes "1.5". Dates use the invariant format: new DateTime(2024, 5, 17) becomes "05/17/2024 00:00:00". Format dates yourself (for example with ToString("yyyy-MM-dd")) if you need another layout.
See also
- Transforming Sequences: reshape and project sequences.
- Statistics: median, percentiles and other measures over numbers.
- Paging and Sorting: pages and
?sort=handling onIEnumerableandIQueryable. - Dictionaries: work with the dictionary that
Frequenciesreturns.