Skip to main content

Transforming Sequences

These methods turn one sequence into another shape: running totals, sliding windows, runs of equal keys, every combination of two lists, a tree built from a flat table, or one async call per item with a limit on how many run at once. Reach for them when plain LINQ would need a hand-written loop with state.

At a glance​

MethodWhat it does
IterateEndless sequence: a seed, then a function applied again and again.
ScanEvery intermediate result of an accumulation: [1, 2, 3, 4] → [1, 3, 6, 10].
WindowSliding windows of a fixed size: [1, 2, 3, 4] → [[1, 2, 3], [2, 3, 4]].
DistinctUntilChangedDrops items equal to the one right before them: [1, 1, 2, 1] → [1, 2, 1].
GroupAdjacentGroups runs of consecutive items with the same key, keeping order.
CartesianProductPairs every item of one sequence with every item of another.
PermutationsEvery ordering of the items.
CombinationsEvery subset of exactly size items.
PowerSetEvery subset, from empty to full.
RecursiveJoinBuilds a tree from a flat list of items that point to their parent.
RecursiveSelectFlattens a tree depth-first, optionally with index and depth.
SelectAsyncRuns an async operation per item with at most N at once; results keep input order.
WithProgressPairs each item with an EnumerationProgress snapshot for progress bars.
EnumerationProgressPosition, elapsed time and estimated time left of a loop.
FractionShare of a loop finished before the current item, from 0 to just below 1.

All of these live in the System.Collections.Generic namespace (class EnumerableExtensions, and the struct EnumerationProgress), so a file that already uses List<T> needs no extra using. The examples also use LINQ (Select, ToList), which needs using System.Linq;.

When is the source read?

Most methods here are lazy and stream the source one item at a time. A few read the whole source first:

MethodReads the source
Iterate, Scan, Window, DistinctUntilChanged, GroupAdjacent, RecursiveSelectLazily, one item at a time.
CartesianProductsource lazily; other once, into a list, when enumeration starts.
Permutations, CombinationsInto an array when enumeration starts.
PowerSet, RecursiveJoin, SelectAsyncInto memory when you call the method.
WithProgressInto a list when enumeration starts, unless it already is an IReadOnlyList<T> (arrays, List<T>).

Argument errors (a null source or delegate, an out-of-range size) are thrown when you call the method, not when you first enumerate the result.

Running values​

Iterate​

Iterate produces an endless sequence: the seed, then next(seed), then next(next(seed)), and so on. Use it for schedules, back-off steps or any series where each value comes from the previous one. Always finish it with Take or TakeWhile.

Doubling back-off and monthly billing dates
IEnumerable<int> backoffSeconds = 1.Iterate(seconds => seconds * 2).Take(5);
// → [1, 2, 4, 8, 16]

DateTime firstBilling = new DateTime(2024, 1, 31);
DateTime contractEnd = new DateTime(2024, 5, 1);

List<DateTime> billingDates = firstBilling
.Iterate(date => date.AddMonths(1))
.TakeWhile(date => date <= contractEnd)
.ToList();
// → [2024-01-31, 2024-02-29, 2024-03-29, 2024-04-29]

next is called only when the next item is requested, so Take(1) never calls it. A null next throws ArgumentNullException. The seed itself may be any value, including null.

warning

Each step works on the previous result, not on the seed. In the example above AddMonths clamps January 31 to February 29, and every later date stays on the 29th. If you need "the last day of every month", compute each date from the seed instead, for example Enumerable.Range(0, 4).Select(i => firstBilling.AddMonths(i)).

Scan​

Scan is Aggregate that returns every intermediate result instead of only the last one: running totals, running maximums, account balances after each payment, breadcrumb paths.

OverloadReturns
Scan(accumulator)The first item, then each accumulated value. The item type stays the same.
Scan(seed, accumulator)One accumulated value per item. The seed itself is not returned, and the result type may differ from the item type.
Running values without a seed
int[] dailyOrders = { 3, 1, 4, 1, 5 };

IEnumerable<int> peakSoFar = dailyOrders.Scan(Math.Max);
// → [3, 3, 4, 4, 5]

string[] segments = { "docs", "api", "v2" };
IEnumerable<string> breadcrumbs = segments.Scan((path, segment) => $"{path}/{segment}");
// → ["docs", "docs/api", "docs/api/v2"]
Account balance after each payment
decimal[] payments = { 120m, -20m, 45.5m };

IEnumerable<decimal> balance = payments.Scan(0m, (total, amount) => total + amount);
// → [120m, 100m, 145.5m]

An empty source yields nothing with either overload. With a single item, Scan(accumulator) yields that item and never calls the accumulator. A null source or accumulator throws ArgumentNullException.

Window​

Window(size) slides a window of size items over the sequence, moving one item at a time. Use it for moving averages, "three failed logins in a row" checks, or comparing each item with its neighbours.

Two-day moving average of revenue
double[] dailyRevenue = { 2.0, 4.0, 6.0, 8.0 };

IEnumerable<double> movingAverage = dailyRevenue.Window(2).Select(window => window.Average());
// → [3.0, 5.0, 7.0]

List<IReadOnlyList<int>> windows = new[] { 1, 2, 3, 4, 5 }.Window(3).ToList();
// → [[1, 2, 3], [2, 3, 4], [3, 4, 5]]

Only full windows are returned, so a sequence shorter than size yields nothing, and a sequence of exactly size items yields one window. Each window is a fresh copy, so it is safe to keep windows after the loop moves on.

size below 1 throws ArgumentOutOfRangeException; a null source throws ArgumentNullException.

tip

Window overlaps. For non-overlapping chunks of a fixed size, use Chunk (.NET 6+) or, on an array, this library's Split(size). For pairs of neighbours with a selector, use Pairwise. Both are listed in the API reference.

Change detection​

DistinctUntilChanged​

DistinctUntilChanged drops an item when it equals the item right before it. Unlike Distinct, a value that comes back later is kept. Use it for status feeds, sensor readings or UI state where only changes matter.

OverloadCompares
DistinctUntilChanged()The items, with default equality.
DistinctUntilChanged(keySelector, comparer = null)The key of each item, with comparer, or default equality when it is null.
Keep only status changes
int[] readings = { 1, 1, 2, 2, 2, 1 };
IEnumerable<int> changes = readings.DistinctUntilChanged();
// → [1, 2, 1]

(int Id, string Status)[] orderEvents =
{
(1, "Open"), (2, "Open"), (3, "Closed"), (4, "Open"),
};

IEnumerable<int> transitionIds = orderEvents
.DistinctUntilChanged(orderEvent => orderEvent.Status)
.Select(orderEvent => orderEvent.Id);
// → [1, 3, 4]

string[] regions = { "eu", "EU", "us", "US", "eu" };
IEnumerable<string> regionChanges = regions.DistinctUntilChanged(region => region, StringComparer.OrdinalIgnoreCase);
// → ["eu", "us", "eu"]

The first item is always returned, even when it equals default (for example 0). null items and keys are compared safely: consecutive nulls collapse into one. A null source or key selector throws ArgumentNullException.

GroupAdjacent​

GroupAdjacent(keySelector) groups runs of consecutive items that share a key. Unlike GroupBy, a key that comes back later starts a new group, and the original order is kept. Use it to split a log into days, compress runs of the same log level, or group sorted rows in one pass.

Each group is a tuple (TKey Key, IReadOnlyList<T> Items).

Runs of the same log level
string[] logLines = { "INFO start", "INFO load", "WARN slow", "INFO done" };

IEnumerable<string> summary = logLines
.GroupAdjacent(line => line.Split(' ')[0])
.Select(run => $"{run.Key} x{run.Items.Count}");
// → ["INFO x2", "WARN x1", "INFO x1"]

foreach ((string level, IReadOnlyList<string> lines) in logLines.GroupAdjacent(line => line.Split(' ')[0]))
{
// First pass: level → "INFO", lines → ["INFO start", "INFO load"]
}

Keys are compared with default equality; there is no comparer overload. null keys are grouped together like any other key. An empty source yields no groups. A null source or key selector throws ArgumentNullException.

Combinatorics​

CartesianProduct​

CartesianProduct pairs every item of the source with every item of other, in order: the first source item with every other item, then the second, and so on. Use it to build product variants, test matrices or price grids.

OverloadReturns
CartesianProduct(other)Tuples (T1 First, T2 Second).
CartesianProduct(other, resultSelector)One resultSelector(first, second) per pair.
Product variants
string[] sizes = { "S", "M" };
string[] colors = { "Red", "Blue" };

IEnumerable<(string First, string Second)> pairs = sizes.CartesianProduct(colors);
// → [("S", "Red"), ("S", "Blue"), ("M", "Red"), ("M", "Blue")]

IEnumerable<string> skus = sizes.CartesianProduct(colors, (size, color) => $"{size}-{color}");
// → ["S-Red", "S-Blue", "M-Red", "M-Blue"]

other is enumerated once per enumeration of the result and buffered, so an expensive query is not re-run for every source item. If either sequence is empty, the result is empty. Any null argument throws ArgumentNullException. For three or more sequences, chain the calls with a result selector.

Permutations​

Permutations() yields every ordering of the items, in lexicographic order of their positions: the source order first, its reverse last. Use it for small brute-force searches, such as trying every delivery route.

Every route through three warehouses
string[] warehouses = { "NYC", "BOS", "PHL" };

IEnumerable<string> routes = warehouses.Permutations().Select(route => string.Join(" > ", route));
// → ["NYC > BOS > PHL", "NYC > PHL > BOS", "BOS > NYC > PHL", "BOS > PHL > NYC", "PHL > NYC > BOS", "PHL > BOS > NYC"]

Each permutation is a fresh IReadOnlyList<T>. An empty source yields one empty permutation; a single item yields one permutation. Items are permuted by position, so equal items produce repeated permutations: new[] { "x", "x" }.Permutations() yields ["x", "x"] twice. A null source throws ArgumentNullException.

warning

There are n! permutations: 10 items already give 3,628,800. The result is lazy, so Take, First or Where stop early, but never call ToList() on the permutations of a large input.

Combinations​

Combinations(size) yields every subset of exactly size items. Inside each subset the source order is kept, and the subsets come in ascending order of positions.

Every pair of reviewers
string[] reviewers = { "ana", "ben", "cho" };

List<IReadOnlyList<string>> pairs = reviewers.Combinations(2).ToList();
// → [["ana", "ben"], ["ana", "cho"], ["ben", "cho"]]

int fiveChooseThree = Enumerable.Range(1, 5).Combinations(3).Count();
// → 10
sizeResult
0One empty subset.
Equal to the item countThe whole sequence, once.
Greater than the item countNothing.
NegativeArgumentOutOfRangeException (parameter size).

A null source throws ArgumentNullException.

PowerSet​

PowerSet() yields every subset of the items, from the empty set to the full set, in increasing bitmask order: subset i contains item k when bit k of i is set. Use it to test every on/off mix of a few feature flags.

Every combination of feature flags
string[] flags = { "dark-mode", "beta-search" };

List<IReadOnlyList<string>> mixes = flags.PowerSet().ToList();
// → [[], ["dark-mode"], ["beta-search"], ["dark-mode", "beta-search"]]

int subsetCount = Enumerable.Range(1, 10).PowerSet().Count();
// → 1024

The items are read once, when you call PowerSet. There may be at most 30 of them (2^30 subsets); a longer source throws ArgumentException (parameter source) at call time. An empty source yields one empty subset. A null source throws ArgumentNullException.

Trees​

RecursiveJoin​

RecursiveJoin builds a tree from a flat list in which each item points to its parent, such as a categories table with Id and ParentId columns. It returns the root nodes; each node is whatever your result selector creates.

The two key selectors are easy to mix up:

ParameterReturns
parentKeySelectorThe item's own key, the one its children point to (category => category.Id).
childKeySelectorThe key of the item's parent (category => category.ParentId).

An item is a root when its parent key matches no item's own key. A null parent key is fine and is the usual way to mark roots.

Category tree from a flat table
public sealed record Category(int Id, int? ParentId, string Name);
public sealed record CategoryNode(string Name, int Depth, IReadOnlyList<CategoryNode> Children);

Category[] categories =
{
new Category(1, null, "Electronics"),
new Category(2, 1, "Phones"),
new Category(3, 1, "Laptops"),
new Category(4, 2, "Accessories"),
new Category(5, null, "Books"),
};

List<CategoryNode> tree = categories
.RecursiveJoin(
category => category.Id,
category => category.ParentId,
(Category category, int index, int depth, IEnumerable<CategoryNode> children) =>
new CategoryNode(category.Name, depth, children.ToList()))
.ToList();

IEnumerable<string> rootNames = tree.Select(node => node.Name);
// → ["Electronics", "Books"]

IEnumerable<string> electronics = tree[0].Children.Select(node => node.Name);
// → ["Phones", "Laptops"]

int accessoriesDepth = tree[0].Children[0].Children[0].Depth;
// → 2

Here TKey is inferred as int?, because Id is int and ParentId is int?. Pass the tree to ToTreeString(node => node.Children, node => node.Name) (see Console output) to print it:

Output
Electronics
├── Phones
│ └── Accessories
└── Laptops
Books

The result selector comes in three shapes, each also available with a trailing IComparer<TKey>? comparer (six overloads in total):

Result selectorReceives
(item, children)The item and its child nodes.
(item, index, children)Also the item's index among its siblings (roots are numbered among roots).
(item, index, depth, children)Also its depth: roots are 0, their children 1, and so on.
Matching keys case-insensitively
public sealed record Department(string Code, string? ParentCode, string Name);

Department[] departments =
{
new Department("SALES", null, "Sales"),
new Department("EMEA", "sales", "Sales EMEA"),
};

List<string> roots = departments
.RecursiveJoin(
department => department.Code,
department => department.ParentCode,
(Department department, IEnumerable<string> children) => department.Name,
StringComparer.OrdinalIgnoreCase)
.ToList();
// → ["Sales"]

With the default comparer (or null), "sales" does not match "SALES", so both departments would be roots.

Give the result selector explicit parameter types

The compiler cannot infer TResult from an implicitly typed lambda here, because TResult also appears in the type of the children parameter. (category, children) => new CategoryNode(...) fails with CS0411. Write the parameter types, as in the examples above, or pass the type arguments: categories.RecursiveJoin<Category, int?, CategoryNode>(...).

Notes:

  • The source is read once, when you call RecursiveJoin, and the key selectors run then (and again while the tree is enumerated). Building the lookup is O(n log n). Keys are compared through an IComparer<TKey>, so the key type must be comparable (int, string, Guid, ...) or you must pass a comparer.
  • Roots and children keep their source order.
  • children is lazy: the result selector of each child runs every time children is enumerated. Call ToList() inside the selector, as above, so each node is built once.
  • Items that only reach each other through a cycle (A's parent is B, B's parent is A) are never roots, so they never appear in the result.
  • An empty source returns no roots. A null source or selector throws ArgumentNullException.

RecursiveSelect​

RecursiveSelect is the opposite of RecursiveJoin: it flattens a tree depth-first, so each item is followed by all its descendants before its next sibling. Use it to search a menu, collect every file in a folder tree, or render an indented outline.

OverloadProjects
RecursiveSelect(childSelector)Nothing; returns the items themselves.
RecursiveSelect(childSelector, item => ...)Each item.
RecursiveSelect(childSelector, (item, index) => ...)Each item with its index among its siblings.
RecursiveSelect(childSelector, (item, index, depth) => ...)Also the depth; top-level items are 0.
Flatten a menu into an indented outline
public sealed record MenuItem(string Title, IReadOnlyList<MenuItem>? Children = null);

MenuItem[] menu =
{
new MenuItem("Settings", new[]
{
new MenuItem("Account", new[] { new MenuItem("Password") }),
new MenuItem("Billing"),
}),
new MenuItem("Help"),
};

IEnumerable<string> titles = menu.RecursiveSelect(item => item.Children).Select(item => item.Title);
// → ["Settings", "Account", "Password", "Billing", "Help"]

IEnumerable<string> positions = menu.RecursiveSelect(
item => item.Children,
(item, index) => $"{item.Title}:{index}");
// → ["Settings:0", "Account:0", "Password:0", "Billing:1", "Help:1"]

IEnumerable<string> outline = menu.RecursiveSelect(
item => item.Children,
(item, index, depth) => $"{new string(' ', depth * 2)}{item.Title}");
Output
Settings
Account
Password
Billing
Help

A null from childSelector means "no children". The walk is lazy: children are requested only when the walk reaches them. An empty source yields nothing; a null source or delegate throws ArgumentNullException.

warning

RecursiveSelect does not detect cycles. If a node is its own descendant, the walk keeps going deeper until the process crashes with a stack overflow (unless you stop early with Take). A very deep tree can overflow the same way.

Async and progress​

SelectAsync​

SelectAsync runs an async operation for every item with at most maxConcurrency operations running at once, and returns the results as an array in the original order. It is the usual way to call an API for 1,000 items without opening 1,000 connections.

OverloadUse when
SelectAsync(selector, maxConcurrency)The operation takes only the item.
SelectAsync((item, token) => ..., maxConcurrency, cancellationToken)The operation should stop on cancellation or when another item fails.
Download invoices, four at a time
string[] invoiceIds = { "INV-1001", "INV-1002", "INV-1003" };

byte[][] pdfs = await invoiceIds.SelectAsync(
(invoiceId, token) => invoiceClient.DownloadPdfAsync(invoiceId, token),
maxConcurrency: 4,
cancellationToken);
// pdfs[0] is the PDF for "INV-1001", whichever download finished first
Results keep the input order
int[] delaysInMilliseconds = { 30, 1, 20, 5, 10 };

int[] results = await delaysInMilliseconds.SelectAsync(
async delay =>
{
await Task.Delay(delay);
return delay;
},
maxConcurrency: 3);
// → [30, 1, 20, 5, 10]

How it behaves:

  • The source is enumerated once, when you call the method. Exactly maxConcurrency operations start at once (fewer if there are fewer items), and each one that finishes makes room for the next item.
  • After the first failure no new operations start, the running ones get a cancelled token, and the original exception is rethrown (not an OperationCanceledException).
  • When cancellationToken is cancelled, no new operations start, running ones see the cancellation through their token, and the returned task fails with OperationCanceledException.
  • An empty source returns an empty array without calling the selector.
  • A null source or selector throws ArgumentNullException; maxConcurrency below 1 throws ArgumentOutOfRangeException. Both are thrown when you call the method, before you await.
note

SelectAsync does not start threads. It calls your selector and awaits the tasks it returns, so it is meant for I/O-bound work: HTTP calls, database queries, file reads. For CPU-bound work, use Parallel.ForEachAsync, or Task.Run inside the selector.

See Tasks for timeouts, retries and other helpers you can combine with the selector.

WithProgress​

WithProgress() pairs every item with an EnumerationProgress snapshot: where the loop is, how long it has run, and how long it will probably still take. Use it for progress bars and status lines in command-line tools and batch jobs.

A status line for a batch job
string[] invoiceFiles = Directory.GetFiles("invoices", "*.pdf");

foreach ((string file, EnumerationProgress progress) in invoiceFiles.WithProgress())
{
Console.Write($"\r{progress} ");
ArchiveInvoice(file);
}
Output (while the fourth of ten files is processed)
4/10 (30%), 1.2s elapsed, ~2.8s left
  • The elapsed time is measured when each item is handed out, so the estimate reflects how long the loop body took for the previous items.
  • If the source is not already an IReadOnlyList<T> (arrays and List<T> are), it is copied into a list when the loop starts, so the total is known. The clock starts after that copy.
  • An empty source yields nothing. A null source throws ArgumentNullException when you call WithProgress.

EnumerationProgress​

EnumerationProgress is a read-only struct that describes one position in a loop. WithProgress creates it for you; you can also create one yourself with new EnumerationProgress(index, total, elapsed), for example in tests.

MemberMeaning
IndexZero-based position of the current item.
TotalTotal number of items.
ElapsedTime since the loop started, measured when the current item was handed out.
CompletedItems handed out before the current one (same as Index).
RemainingItems still to come after the current one.
FractionCompleted share: 0 for the first item, just below 1 for the last.
PercentFraction × 100.
IsLasttrue for the last item.
EstimatedRemainingTime left, assuming the remaining items take as long on average as the completed ones; null for the first item.
ToString()A status line, formatted with the invariant culture.
Equals, ==, !=, GetHashCodeValue equality on Index, Total and Elapsed.
Reading a snapshot
EnumerationProgress progress = new EnumerationProgress(4, 10, TimeSpan.FromSeconds(2));

int completed = progress.Completed; // → 4
int remaining = progress.Remaining; // → 5
double percent = progress.Percent; // → 40.0
bool isLast = progress.IsLast; // → false
TimeSpan? timeLeft = progress.EstimatedRemaining; // → TimeSpan.FromSeconds(3)
string statusLine = progress.ToString(); // → "5/10 (40%), 2.0s elapsed, ~3.0s left"

EnumerationProgress first = new EnumerationProgress(0, 10, TimeSpan.FromMilliseconds(500));
string firstLine = first.ToString(); // → "1/10 (0%), 500ms elapsed"
TimeSpan? noEstimate = first.EstimatedRemaining; // → null

ToString() shows times under a second as milliseconds (500ms), under a minute with one decimal (1.2s), and longer ones as minutes and seconds (5m 00s). The "left" part is missing for the first item, because there is nothing to extrapolate from yet.

tip

Percent is the share finished before the current item, so it never reaches 100 inside the loop: for 10 items the last one shows 90%. Print a final "done" line after the loop if you want to show 100%.

The constructor throws ArgumentOutOfRangeException when total is negative (parameter total), or when index is negative or not below total (parameter index).

Fraction​

Fraction is the share of the loop finished before the current item, as a double from 0 to just below 1. It is Index / Total. Use it when a progress bar or UI control wants a ratio rather than a percentage; use Percent when you want 0 to 100.

Drawing a text progress bar for an order import
EnumerationProgress progress = new EnumerationProgress(4, 10, TimeSpan.FromSeconds(2));

double fraction = progress.Fraction; // → 0.4
int filled = (int)(fraction * 20); // → 8
string bar = new string('#', filled).PadRight(20, '.'); // → "########............"

EnumerationProgress first = new EnumerationProgress(0, 3, TimeSpan.Zero);
double atStart = first.Fraction; // → 0.0

EnumerationProgress second = new EnumerationProgress(1, 3, TimeSpan.FromSeconds(1));
double afterOne = second.Fraction; // → 0.3333333333333333

In a loop, read it from the snapshot that WithProgress hands you:

Reporting a ratio to a progress control
IProgress<double> importProgress = new Progress<double>(ratio => progressBar.Value = ratio);
List<Order> pendingOrders = orderRepository.GetPending();

foreach ((Order order, EnumerationProgress progress) in pendingOrders.WithProgress())
{
importProgress.Report(progress.Fraction);
ImportOrder(order);
}
  • Fraction never reaches 1 inside the loop: for 10 items the last one gives 0.9. Report 1 yourself after the loop ends.
  • A snapshot made by the constructor or by WithProgress always has Total of at least 1, because the constructor rejects an index that is not below total and WithProgress yields nothing for an empty source. So Fraction is never NaN there.
warning

default(EnumerationProgress) skips the constructor and has Total 0, so its Fraction is NaN (0.0 / 0). Do not use a default snapshot as a placeholder for "no progress yet".

See also​

  • Querying sequences for filtering, partitioning and other LINQ additions.
  • Statistics for medians, percentiles and other numbers over sequences.
  • Console output for printing trees and tables, and for ToCsv.
  • Tasks for timeouts, retries and awaiting several tasks at once.