C# LINQ Filtering and Projection

Filtering and projection are the two LINQ operations you will use most often. Filtering chooses which items stay in a sequence, while projection changes each kept item into the shape your program needs. Together, Where and Select let you turn raw collections into focused results without writing repetitive loops.

Overview: How Filtering And Projection Work

In LINQ, a sequence is usually represented by IEnumerable<T>, meaning it can produce values of type T one at a time. Where filters a sequence by calling a predicate for each item. A predicate is a function that returns true or false. If the predicate returns true, the item is yielded to the next part of the query; if it returns false, the item is skipped.

Select projects each item into another value. Projection might be simple, such as converting a name to uppercase, or it might create a new object containing selected fields. The output type does not have to match the input type. A List<Product> can become an IEnumerable<string>, an IEnumerable<decimal>, or an enumerable of anonymous objects with named properties.

Most LINQ filtering and projection over in-memory collections uses deferred execution. Calling Where or Select usually creates an iterator object that stores the source sequence and your lambda. The query does not run immediately. It runs when something enumerates it, such as foreach, ToList(), ToArray(), Count(), First(), or string.Join. This is why LINQ can stream data efficiently: for a chain like source.Where(...).Select(...), each item can be tested and transformed before the next item is requested.

Under the hood, these methods are extension methods from System.Linq.Enumerable. The compiler turns your lambda expressions into delegates such as Func<Product, bool> for a filter or Func<Product, string> for a projection. The CLR then executes ordinary method calls, delegate invocations, and enumerator movement. LINQ feels like a query language, but for IEnumerable<T> it is built from normal C# types and methods.

Syntax

int[] source = { 1, 2, 3, 4 };
IEnumerable<int> filtered = source.Where(item => item > 2);
IEnumerable<string> projected = source.Select(item => $"Value {item}");
var combined = source
    .Where(item => item > 2)
    .Select(item => new { Original = item, Doubled = item * 2 });
Part Meaning
source The input sequence, such as an array, list, dictionary entries, or another LINQ query.
Where Keeps only items whose predicate returns true.
Select Converts each input item into one output value.
item => item > 2 A lambda that receives one item and returns a Boolean value.
item => $"Value {item}" A lambda that receives one item and returns the projected value.
new { ... } An anonymous object, useful for temporary report-style results.

When writing a full program, include the System.Linq namespace. If you use IEnumerable<T> or List<T> explicitly, include System.Collections.Generic too.

Examples

Example 1: Filter Numbers And Project Text

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        int[] scores = { 55, 72, 88, 91, 64, 100 };

        var labels = scores
            .Where(score => score >= 80)
            .Select(score => $"Pass with {score}");

        foreach (string label in labels)
        {
            Console.WriteLine(label);
        }
    }
}

Output:

Pass with 88
Pass with 91
Pass with 100

Where keeps only scores greater than or equal to 80. Select then turns each remaining integer into a string label. Notice that the input sequence contains integers, but the final query produces strings.

Example 2: Project Objects Into A Report Shape

using System;
using System.Collections.Generic;
using System.Linq;

record Product(string Name, string Category, decimal Price, int Stock);

class Program
{
    static void Main()
    {
        var products = new List<Product>
        {
            new Product("Notebook", "Office", 4.50m, 20),
            new Product("Pen", "Office", 1.25m, 80),
            new Product("Mug", "Kitchen", 9.99m, 12),
            new Product("Tea", "Kitchen", 6.40m, 0),
            new Product("Cable", "Electronics", 12.00m, 7)
        };

        var available = products
            .Where(product => product.Stock > 0)
            .Select(product => new
            {
                product.Name,
                product.Category,
                InventoryValue = product.Price * product.Stock
            });

        foreach (var item in available)
        {
            Console.WriteLine($"{item.Name} ({item.Category}): {item.InventoryValue:F2}");
        }
    }
}

Output:

Notebook (Office): 90.00
Pen (Office): 100.00
Mug (Kitchen): 119.88
Cable (Electronics): 84.00

This query removes out-of-stock products and projects each remaining product into an anonymous object. Anonymous objects are strongly typed; the compiler knows that item.Name, item.Category, and item.InventoryValue exist inside the same method. They are ideal for temporary summaries, but for public APIs and long-lived results, prefer a named class or record.

Example 3: Use Indexes And Flatten Nested Lists

using System;
using System.Collections.Generic;
using System.Linq;

record Course(string Title, List<string> Students);

class Program
{
    static void Main()
    {
        var courses = new List<Course>
        {
            new Course("C#", new List<string> { "Ava", "Noah" }),
            new Course("SQL", new List<string> { "Mina" }),
            new Course("Python", new List<string> { "Omar", "Ivy" })
        };

        var courseLabels = courses.Select((course, index) => $"{index + 1}. {course.Title}");
        Console.WriteLine(string.Join(" | ", courseLabels));

        var enrollments = courses
            .SelectMany(course => course.Students,
                (course, student) => $"{student} studies {course.Title}");

        foreach (string enrollment in enrollments)
        {
            Console.WriteLine(enrollment);
        }
    }
}

Output:

1. C# | 2. SQL | 3. Python
Ava studies C#
Noah studies C#
Mina studies SQL
Omar studies Python
Ivy studies Python

Select has an overload that provides the zero-based index of each item. This is useful for labels, row numbers, and alternating display rules. SelectMany is projection plus flattening: each course produces several students, and LINQ returns one flat sequence of enrollment strings instead of a sequence of nested student lists.

How It Works Step By Step

  1. The compiler finds the extension method Enumerable.Where or Enumerable.Select because System.Linq is in scope.
  2. The lambda is type-checked against the source item type. In products.Where(product => product.Stock > 0), product is a Product.
  3. Where stores the source sequence and predicate delegate in an iterator object. Select stores its source and selector delegate in another iterator object.
  4. When enumeration starts, the outer iterator asks the previous iterator for the next value. The chain pulls values from the original source only as needed.
  5. For each source item, Where calls the predicate. Matching items move forward; nonmatching items are discarded.
  6. Select calls the selector only for items it receives and yields the transformed result.
  7. If you call ToList(), the query is fully consumed and the results are copied into a new list. Without materialization, repeating enumeration can repeat the filtering and projection work.

Because Where and Select are streaming operators, they do not need to store the entire result before producing the first value. This differs from operators such as OrderBy, which must inspect and buffer the sequence before it can return sorted output.

Common Mistakes

Projecting Before Filtering When The Projection Is Expensive

using System;
using System.Linq;

class Program
{
    static string BuildLabel(int number)
    {
        Console.WriteLine($"Building label for {number}");
        return $"Value {number}";
    }

    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4 };

        var labels = numbers
            .Select(number => new { Number = number, Label = BuildLabel(number) })
            .Where(item => item.Number % 2 == 0)
            .Select(item => item.Label);

        foreach (string label in labels)
        {
            Console.WriteLine(label);
        }
    }
}

Output:

Building label for 1
Building label for 2
Value 2
Building label for 3
Building label for 4
Value 4

This code is valid, but it builds labels for odd numbers that will be discarded. When possible, filter first and then project.

using System;
using System.Linq;

class Program
{
    static string BuildLabel(int number)
    {
        Console.WriteLine($"Building label for {number}");
        return $"Value {number}";
    }

    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4 };

        var labels = numbers
            .Where(number => number % 2 == 0)
            .Select(number => BuildLabel(number));

        foreach (string label in labels)
        {
            Console.WriteLine(label);
        }
    }
}

Output:

Building label for 2
Value 2
Building label for 4
Value 4

Forgetting That Deferred Queries See Later Changes

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        List<string> names = new List<string> { "Ava", "Noah", "Mina" };
        var shortNames = names.Where(name => name.Length == 3);

        names.Add("Ivy");

        Console.WriteLine(string.Join(", ", shortNames));
    }
}

Output:

Ava, Ivy

The query was created before Ivy was added, but it was enumerated afterward. If you need a stable snapshot, materialize the query immediately.

using System;
using System.Collections.Generic;
using System.Linq;

class Program
{
    static void Main()
    {
        List<string> names = new List<string> { "Ava", "Noah", "Mina" };
        List<string> shortNames = names.Where(name => name.Length == 3).ToList();

        names.Add("Ivy");

        Console.WriteLine(string.Join(", ", shortNames));
    }
}

Output:

Ava

Best Practices

  • Use Where before Select when the filter can reduce the amount of projection work.
  • Keep predicates and selectors small. Move complex logic into named methods with clear names.
  • Project only the fields you actually need, especially for reports and API responses.
  • Use anonymous objects for local, temporary shapes; use records or classes for values that cross method boundaries.
  • Remember that Where and Select are deferred. Use ToList() or ToArray() when you need a snapshot.
  • Avoid side effects inside LINQ lambdas. A query may run later, more than once, or not at all.
  • Use SelectMany when each input item contains a collection and you want one flat result sequence.
  • Prefer readable query chains over clever one-liners. If a chain becomes hard to scan, split it into named intermediate variables.

Practice Exercises

  1. Create a list of temperatures. Use Where to keep values above 75 and Select to format them as strings such as Hot: 82.
  2. Create a Book record with title, author, and page count. Print only books with more than 300 pages, projected into strings like Title by Author.
  3. Create a list of departments, where each department has a list of employee names. Use SelectMany to print one line per employee with the department name.

Summary

  • Where filters a sequence by keeping items whose predicate returns true.
  • Select projects each input item into a new output value, often with a different type.
  • Filtering and projection are streaming, deferred LINQ operations over IEnumerable<T>.
  • The compiler type-checks lambdas and LINQ calls as ordinary generic method calls.
  • Anonymous objects are useful for local report shapes, while named records or classes are better for shared results.
  • SelectMany projects nested collections and flattens them into one sequence.
  • Materialize with ToList() or ToArray() when later source changes should not affect the result.