LINQ ships with a long list of operators, but every codebase eventually grows a few queries that show up again and again. "Products that are in stock with rating at least four." "Orders placed in the last seven days." Repeating that predicate in twenty places is how bugs creep in. A custom LINQ operator wraps the predicate (or projection, or aggregation) into an extension method with a domain name, so the query reads like the business rule. This lesson covers how to write those operators, when to compose existing ones, when to write your own iterator with yield return, and the eager-validation pattern that makes argument errors easy to find.
A query like this works:
It works, but the two Where clauses are noise. "In stock" and "rated four or above" are domain ideas, and the query is forcing the reader to translate predicates back into those ideas. The next time someone writes the same filter, they'll write it slightly differently. One person uses Stock > 0, another uses Stock >= 1, a third uses !OutOfStock. Three subtle variants, all meaning the same thing.
A custom operator fixes that:
The query now reads like the requirement. There's one definition of "in stock" and one definition of "minimum rating," and the rest of the codebase calls those by name. When the business changes the rule (out of stock now means Stock < 5 because of fulfillment lead time), there's one place to change it.
Custom operators are not magic. They're extension methods on IEnumerable<T> that return another IEnumerable<T> (or sometimes a different shape, like an aggregate). Once they're in scope, they slot into a LINQ chain like any built-in operator.
The diagram shows that custom operators are peers of built-in ones, not replacements. A pipeline can mix them freely: source.Where(...).InStock().Select(...).TopByPrice(5). The compiler treats them identically once the extension namespace is imported.
A custom LINQ operator is just an extension method on IEnumerable<T>. The shape is fixed:
Three things matter. The class must be static. The method must be static. The first parameter must use the this modifier, which tells the compiler "this method extends IEnumerable<T>." Once those three are in place, any code with using YourNamespace; can call the method as if it were defined on IEnumerable<T> directly.
The generic type parameter <T> lets the operator work on any element type. InStock only makes sense for Product, so it doesn't need <T>. WhereNotNull needs to work for any reference type, so it takes <T>. Match the generic-ness to the operation.
Here's the simplest concrete example, InStock, written as a domain-specific operator on Product:
Client code uses both:
Neither operator needs to know how the other works. InStock returns an IEnumerable<Product>, and WithMinRating accepts an IEnumerable<Product>. The chain just works.
The two operators above didn't introduce any new iteration logic. They just delegated to Where. That's the simplest form of a custom operator: take existing LINQ pieces and bundle them under a name.
TopByPrice is another example. It sorts by price descending and takes the top N:
Client:
There's no yield return here. OrderByDescending and Take already return lazy IEnumerable<T> sequences, so TopByPrice inherits their laziness. The whole chain only runs when something enumerates the result (the foreach in this case).
This is the rule of thumb: if your operator can be expressed as a chain of existing LINQ calls, just return that chain. Don't write an iterator. Writing yield return when Where(...).Select(...) would do is extra code with no benefit.
OrderByDescending is not free. It buffers the entire source to do the sort, so TopByPrice materializes the input into memory. For a small product catalog that's fine. For a million-row stream from a database, prefer database-side sorting.
yield returnSometimes the existing operators don't quite cover what you need. Batch, for example, takes an IEnumerable<T> and yields fixed-size chunks: [1, 2, 3, 4, 5, 6, 7] with size 3 becomes [1, 2, 3], [4, 5, 6], [7]. None of the built-in operators do that. (.NET 6 added Chunk, but for the sake of the example, let's pretend it doesn't exist.)
To write Batch, the operator needs its own iteration. That's where yield return comes in:
A method that returns IEnumerable<T> and uses yield return is rewritten by the compiler into a state machine. The body doesn't execute when you call Batch(...). It executes piece by piece as the consumer calls MoveNext() on the iterator, which is what foreach does internally.
Client:
The iterator yields three batches. The first two are full, the third holds the remainder. The implementation reuses the bucket list inside the method but allocates a fresh one between yields, so the consumer's batches don't share memory.
Each yielded IReadOnlyList<T> is a snapshot of that chunk. If the operator yielded the same bucket and cleared it for the next batch, callers who held onto the first batch would see it change. Allocating a new list per batch costs a little memory but avoids that error-prone behavior.
Batch allocates one list per chunk. For a million items with size 100, that's ten thousand small lists. If allocation is a bottleneck, consider ArrayPool<T> or the newer Chunk operator in .NET 6+, which uses arrays.
Iterators have a subtle problem. The method body runs lazily, including argument checks:
Looks reasonable. But because the method uses yield return, the entire body, including the null check, is deferred. The exception doesn't throw until someone enumerates the result. That makes debugging painful. The stack trace points to the consumer's foreach, not the line that passed null.
A demo:
The "Got the iterator" line prints before the exception. The caller's stack trace points to the foreach, not to WhereNotNull(...). A bug like "I passed null somewhere and got ArgumentNullException in a totally unrelated loop" is unpleasant to track down.
The fix is to split the method into two: a public wrapper that validates eagerly, and a private iterator that does the yielding. The wrapper isn't an iterator, so its body runs immediately:
Now the null check fires the instant the caller invokes WhereNotNull(null), with a stack trace pointing exactly to the call. The actual filtering still happens lazily inside the iterator.
Client demo:
The exception fires before the "Got the iterator" line ever runs. The error surfaces at the call site, not deep inside a foreach ten methods away.
The same pattern applies to any pre-iteration check: argument validation, range checks, type checks. If the check has to happen before iteration starts, it belongs in a non-iterator wrapper.
Pagination shows up everywhere. The pattern is "skip (pageNumber - 1) * pageSize items, then take pageSize items." Repeating that math is how off-by-one bugs are born.
This one validates eagerly without splitting into a wrapper. The body uses Skip and Take, not yield return. The validation runs immediately, the LINQ chain it returns is lazy. Argument errors surface at the call site, results are still deferred.
Client:
The arithmetic is in exactly one place. Add a pageCount parameter, change to zero-based pages, switch to cursor-based pagination, all of those are one-line edits.
Skip walks past (pageNumber - 1) * pageSize items even though it discards them. For page 100 of 50 items each, that's 4950 items iterated and thrown away. For in-memory lists, this is fine. For database queries through IQueryable, the LINQ provider translates Skip/Take into SQL OFFSET/LIMIT, which the database does efficiently.
Not every custom operator returns a sequence. Some collapse a sequence to a single value, like Sum, Average, or Count. Those go in the same extension class and follow the same shape, just with a different return type.
AverageRating is a small example:
Client:
AverageRating is eager because Average is eager. Aggregating operators force enumeration; there's no way to "lazily compute an average." That's expected, and you don't need a wrapper because there's no iterator to defer.
The handling of empty input matters. The built-in Average on a numeric sequence throws InvalidOperationException when called on an empty sequence. Returning 0 for "no products" is a domain decision, not a LINQ rule. Bake the right default for your business into the operator.
A custom operator only helps if developers can find it. Two practical rules.
First, put related operators together in one extension class. ProductQueries for product-specific operators, EnumerableExtensions for generic ones. Mixing them dilutes both.
Second, name them like LINQ verbs. PascalCase, verb first, no Get prefix, no s plural on the method name. InStock, not GetInStockProducts. WithMinRating, not FilterByRating. TopByPrice, not GetTopProductsByPrice. The built-in LINQ vocabulary (Where, Select, OrderBy, Take, Skip, GroupBy) sets the tone. Match it.
A namespace convention helps too. Put extensions in a namespace named after the domain area, like MyApp.Catalog.Linq or MyApp.Sales.Linq. Developers add the using directive when they want those operators in scope, and IntelliSense shows them alongside the built-ins. Without the using, the operators are invisible. That's a feature, not a bug: it keeps unrelated query helpers from polluting every file.
A small worked example that uses everything in this lesson. The scenario is an e-commerce app needing to show "page 1 of in-stock products, rated four or above, sorted by price ascending."
The query reads almost like the requirement. "In stock, with minimum rating four, by price ascending, page one of three." Each operator does one thing and combines cleanly with the rest. The reader doesn't need to know how PageBy is implemented to understand what the query does.
This is where custom operators pay off. The implementation is a few dozen lines, the readability gain is across every place in the codebase that needs the same query.
10 quizzes