Most queries you write boil down to two questions: "which rows do I want?" and "in what order do I want them?" LINQ answers the first question with operators like Where, OfType, Distinct, Take, and Skip, and the second with OrderBy, ThenBy, and Reverse. This lesson covers each of those operators, the overloads that matter in real code, and the performance characteristics that decide how you chain them.
WhereWhere keeps the elements that match a predicate and drops the rest. The predicate is a Func<T, bool>, and the operator returns an IEnumerable<T> of the same element type.
The predicate runs once per element. Where is lazy, meaning the filtering only happens when you iterate the result, which is why the foreach is the part that actually does the work.
You can chain multiple Where calls, and each one filters the previous result:
Two filters or one combined filter produce the same result. Where(a).Where(b) is equivalent to Where(x => a(x) && b(x)). The chained form is usually easier to read because each line states one intent.
Each Where in a chain adds one pass over the matched elements. The total cost is still O(n) because the chain runs interleaved per element (not n separate passes), but every additional predicate is one more function call per element. For two or three filters this is irrelevant. For a chain of fifteen, combine them.
Where has a second overload, Where<T>(this IEnumerable<T> source, Func<T, int, bool> predicate), where the predicate also receives the zero-based index of the element in the source. This is useful when "position in the list" is part of the filter.
The index is the position in the source sequence, not in the filtered output. So if you chain Where calls, the index in the second one is the position in the result of the first, not in the original.
OfType<T>() for Heterogeneous SequencesWhere filters by a value condition. OfType<TResult> filters by runtime type. If you have an IEnumerable of mixed types (or a sequence typed as object), OfType<TResult> keeps only the elements that are actually TResult and casts each one for you.
A few details to know. First, OfType<T> drops null entries silently. The null in the list above isn't a string, isn't a decimal, and doesn't appear in either result. Second, the operator does the type test and the cast in one step, so the result is strongly typed as IEnumerable<T> rather than IEnumerable<object>.
The closest sibling is Cast<T>, which assumes every element is already a T and throws InvalidCastException on the first one that isn't. Use Cast<T> when you know the sequence is homogeneous (you're just changing the static type). Use OfType<T> when the sequence might contain other types and you want to keep only the matches.
OfType<T> is also useful for filtering an inheritance hierarchy. Given a list typed as IEnumerable<Order>, you can pull out the PriorityOrder subset with orders.OfType<PriorityOrder>(). No null checks, no manual is tests.
Distinct and DistinctByDistinct() returns each unique value once, in the order they first appear. It uses the default equality comparer for the element type.
For value types and string, Distinct works the way you expect because equality is value-based. For reference types, it uses reference equality unless you override Equals and GetHashCode, or pass an IEqualityComparer<T> to the Distinct(IEqualityComparer<T>) overload.
The interesting one is DistinctBy, added in .NET 6. It takes a key selector and returns the first element for each distinct key.
Before DistinctBy existed, the idiomatic pattern was GroupBy(key).Select(g => g.First()). That works but is more expensive and harder to read.
The GroupBy form has to materialize every group, even though you only want one element from each. DistinctBy walks the sequence once with a HashSet of seen keys and yields each first occurrence. For a million-row sequence with 100 distinct keys, DistinctBy allocates a 100-entry hash set; the GroupBy form allocates a million entries across all groups. Prefer DistinctBy when you want "one per key."
DistinctBy is O(n) with an internal HashSet<TKey> for tracking seen keys. The hash set's memory grows with the number of distinct keys, not with the input size. GroupBy(...).Select(g => g.First()) is also O(n) but stores every element grouped by key before discarding all but the first.
Take, Skip, TakeLast, SkipLastThese four operators carve a window out of a sequence. They're the building blocks of paging.
| Operator | Behavior |
|---|---|
Take(n) | First n elements (or fewer if the sequence is shorter) |
Skip(n) | Everything after the first n |
TakeLast(n) | Last n elements |
SkipLast(n) | Everything except the last n |
Take and Skip are the classic paging pair. To get page p with page size s, write source.Skip((p - 1) * s).Take(s):
The last page has only three items because there are 23 products total. Take doesn't throw when the sequence runs out, it just returns what's available.
Take also has a range overload (Take(Range)) added in .NET 6 that maps cleanly to slice syntax:
The actual output:
Take(Range) is the readable choice when the slice has a fixed shape. For paging, the Skip(...).Take(...) form is still more common because the range bounds usually come from variables.
Take(n) is O(min(n, length)) and streams. Skip(n) is O(length) because it has to walk past the skipped elements (unless the source is an IList<T> or array, in which case the LINQ implementation may skip directly). TakeLast(n) is the tricky one: when the source is an IList<T> or ICollection<T>, it goes straight to the tail. When the source is a pure sequence (a yield-returning method, a Where chain, a database query), TakeLast has to buffer the last n elements in a queue while walking the entire sequence.
The buffering for TakeLast matters when the source is large or infinite:
The memory is only three elements (the rolling window), but the work is a million enumerations. If the source were an actual List<int>, the same call would jump straight to the tail.
TakeWhile and SkipWhileThese two operators stop or start when a predicate first changes its answer. TakeWhile(p) yields elements as long as p returns true, and stops at the first false. SkipWhile(p) does the inverse: it discards elements while p is true, and yields everything from the first false onward.
Use TakeWhile when you want a prefix of the sequence that satisfies a condition. The first failing element ends the result. This is different from Where(p). Where keeps every element that passes the predicate, even after a failing one. TakeWhile stops the moment the predicate fails.
SkipWhile is the mirror. Use it to skip a leading run of values you don't care about:
SkipWhile doesn't re-check the predicate once it starts yielding. The 1 at the end stays in the result, even though it would satisfy a "less than 3" filter, because SkipWhile only cares about the leading run.
Both operators have index overloads ((element, index) => bool) that work the same way as Where's index overload.
OrderBy, OrderByDescending, ThenByOrderBy(keySelector) returns the sequence sorted by the chosen key in ascending order. OrderByDescending does the same, descending. The return type is IOrderedEnumerable<T>, a more specific type than IEnumerable<T> that exists for one reason: to make ThenBy and ThenByDescending available.
The sort is stable in LINQ to Objects, which means elements that compare equal keep their relative order from the source. This matters more than you'd expect; we'll come back to it when we look at multi-key sorting.
OrderBy is O(n log n) and materializes the entire sequence before yielding the first element. There's no "lazy sort." If you chain Where().OrderBy().Take(10) to get the top 10, the sort still walks the full filtered set. The _Parallel LINQ (PLINQ)_ lesson discusses PriorityQueue<T>-based alternatives.
OrderByDescending is the standalone descending sort. Don't write OrderBy(...).Reverse(); that's two passes and produces a different result for ties because Reverse flips the stable order of equal-keyed elements. We cover Reverse later in the lesson.
ThenBy and ThenByDescendingWhen two elements have the same primary key, you usually want a tiebreaker. ThenBy adds a secondary key, ThenByDescending adds a secondary descending key. You can chain as many as you need.
The sort runs in three stages: group by customer (ascending), within each customer group sort by date (ascending), within each date group sort by total (descending). The compiler enforces a subtle rule: ThenBy is defined as an extension method on IOrderedEnumerable<T>, not on IEnumerable<T>. If you tried to call products.ThenBy(...) without a preceding OrderBy, the code wouldn't compile.
The diagram shows the type flow. The intermediate IOrderedEnumerable<T> is the only type that exposes ThenBy and ThenByDescending. Once you enumerate the result, you're back to a plain sequence.
Why this design instead of just letting you chain OrderBy(x).OrderBy(y)? Because that would mean "sort by x, then re-sort by y from scratch", which discards the first sort entirely (since OrderBy doesn't preserve a previous order as a tiebreaker). ThenBy exists so the compiler can tell the two intents apart.
The wrong form sorts by customer first, then resorts by total, throwing away the customer order. The right form keeps customers grouped and breaks ties with the total.
IComparer<TKey>Every OrderBy and ThenBy overload has a second form that takes an IComparer<TKey>. Use it when the default comparison doesn't match what you want.
The default string comparer would put all uppercase letters before any lowercase letter (because that's the Ordinal Unicode order), giving you Headphones, Keyboard, MONITOR, mouse, webcam in a different position. StringComparer.OrdinalIgnoreCase treats case as irrelevant, which is usually what e-commerce search wants.
For custom types, the convenient way to define an order is Comparer<T>.Create:
Without the custom comparer, OrderBy(o => o.Priority) would have used the default string order and produced high, high, low, low, medium, which is alphabetical but not what anyone means by "priority". The comparer is the escape hatch when "order" is domain-specific.
ReverseLINQ's Reverse() returns a new sequence with the elements in opposite order. It does not mutate the source.
Watch the type here. Enumerable.Reverse<T>(IEnumerable<T>) is the LINQ operator. List<T>.Reverse() is an instance method on List<T> that reverses the list in place and returns nothing. They have the same name but very different behavior.
The first call mutated products. The second call, routed through AsEnumerable(), hit the LINQ operator and produced a fresh sequence without touching the underlying list. When you have a List<T> and want the LINQ behavior, AsEnumerable() or casting (((IEnumerable<T>)products).Reverse()) disambiguates.
Enumerable.Reverse<T> has to buffer the source into a temporary array before yielding the first element, because it can't yield "last" until it knows what last is. That's O(n) memory on top of the source. For an IList<T>, the BCL implementation skips the buffering and walks indices backward.
For ordering purposes, prefer OrderByDescending over OrderBy(...).Reverse(). They look equivalent for simple cases, but they're not.
OrderByDescending is stable, so the three 50s come out in source order (A, B, D). OrderBy(...).Reverse() first sorts (still stable, so C-A-B-D), then flips the entire sequence (D-B-A-C), which reverses the tie order. If you care about which equal-valued element comes first, the two operators are not interchangeable.
A realistic query usually chains a few of these. A common shape: filter to in-stock products, drop duplicates by category, sort by price, take the top three, render them.
The pipeline reads top-to-bottom as a sequence of intent: skip the unavailable, keep one per category, sort by price (desc) with a name tiebreaker, take three. Each operator does one job and hands its result to the next. That's the readability argument for chaining filters and sorts instead of doing it all in one custom loop.
The diagram traces the data through the chain. Two notes about cost in this pipeline. First, Where and DistinctBy stream, so they don't materialize until something downstream asks for results. Second, OrderByDescending is the materialization point: it buffers and sorts the three remaining elements, then ThenBy adds its tiebreaker to the same sort, and Take(3) walks the sorted result.
A condensed table of the costs that come up in this lesson.
| Operator | Time | Space | Notes |
|---|---|---|---|
Where(p) | O(n) | O(1) | Streams. Lazy. |
Where((e,i) => ...) | O(n) | O(1) | Index is source-relative. |
OfType<T>() | O(n) | O(1) | Drops nulls and mismatched types. |
Distinct() | O(n) | O(d) | d = distinct count, kept in a HashSet. |
DistinctBy(k) | O(n) | O(d) | Same as Distinct, indexed by key. |
Take(n) | O(min(n, length)) | O(1) | Streams. |
Skip(n) | O(n) | O(1) | Walks past the skipped prefix. |
TakeLast(n) | O(length) | O(n) | Buffers a rolling window for non-IList. |
SkipLast(n) | O(length) | O(n) | Buffers a rolling window for non-IList. |
TakeWhile(p) | O(k) | O(1) | k = elements until predicate fails. |
SkipWhile(p) | O(length) | O(1) | Walks the prefix, then streams. |
OrderBy / OrderByDescending | O(n log n) | O(n) | Materializes. Stable. |
ThenBy / ThenByDescending | folded into the OrderBy sort | O(n) | Same sort, more keys. |
Reverse() | O(n) | O(n) | Buffers, then yields backward. |
The non-obvious entries are Skip, TakeLast, and Reverse. Skip looks like it should be free but isn't; it walks the prefix unless the source is an IList<T> and the operator can short-circuit. TakeLast and Reverse allocate because you can't know the end of a stream without consuming it.
10 quizzes