BrandGhost
LINQ Projection in C#: Select, SelectMany, and Flattening Collections

LINQ Projection in C#: Select, SelectMany, and Flattening Collections

LINQ projection in C# is how you transform data from one shape into another without writing verbose foreach loops and temporary lists. The Select and SelectMany operators sit at the heart of every data pipeline -- whether you're mapping database records to DTOs, flattening a product catalog's nested category tree, or building read-model projections for a query handler. Understanding the difference between Select and SelectMany, knowing when to use anonymous types vs records, and leveraging the .NET 9 Index() method will make your LINQ projection code cleaner and more expressive.

This article covers every major projection pattern with realistic domain examples, before/after comparisons for .NET 9 additions, and clear guidance on which tool to reach for in each scenario.


Select(selector): Transforming Individual Elements

Select maps every element in a source sequence through a transformation function, producing a new sequence of the same length. It is a one-to-one mapping -- one input element produces exactly one output element.

namespace ECommerce.Catalog;

public sealed record Product(
    string Sku,
    string Name,
    decimal Price,
    string CategoryName);

// Project to a single value
IEnumerable<string> skus = products.Select(p => p.Sku);
IEnumerable<decimal> prices = products.Select(p => p.Price);

// Project to an anonymous type
var summaries = products.Select(p => new
{
    p.Sku,
    p.Name,
    DiscountedPrice = p.Price * 0.9m
});

// Project to a named record
public sealed record ProductSummary(string Sku, string Name, decimal DiscountedPrice);

IEnumerable<ProductSummary> dtos = products.Select(p =>
    new ProductSummary(p.Sku, p.Name, p.Price * 0.9m));

Select is lazy -- the transformation function is not called until you iterate the result. This means you can compose multiple Select calls in a pipeline without materializing intermediate collections.


Select with Index: Enumerating Position

Select has an overload that provides the index of each element alongside the element itself. This is useful when position in the sequence carries meaning -- rendering numbered lists, pairing items with ordinal identifiers, or detecting the first/last item during transformation.

namespace ECommerce.Catalog;

// Before .NET 9 -- Select overload with index
IEnumerable<string> numberedNames =
    products.Select((p, i) => $"{i + 1}. {p.Name}");

// Project to a tuple with position
IEnumerable<(int Position, string Sku)> positioned =
    products.Select((p, i) => (i, p.Sku));

// Find first and last without Count()
var withIndex = products.Select((p, i) => (Index: i, Product: p)).ToList();
var first = withIndex.First();
var last = withIndex.Last();

.NET 9: Index() Method

.NET 9 introduces Index() as a first-class operator that replaces the Select((item, i) => (i, item)) idiom:

namespace ECommerce.Catalog;

// Before .NET 9 -- verbose tuple construction
IEnumerable<(int Index, Product Item)> indexedOld =
    products.Select((p, i) => (i, p));

// .NET 9 -- Index() is clean and self-documenting
IEnumerable<(int Index, Product Item)> indexed = products.Index();

// Practical use: render a ranked product list
foreach (var (i, product) in products.OrderByDescending(p => p.Price).Index())
{
    Console.WriteLine($"#{i + 1}: {product.Name} -- {product.Price:C}");
}

Index() makes intent explicit -- it says "I want this sequence with its indices" without the noise of a lambda that just pairs the element with its position.


SelectMany: Flattening Nested Collections

SelectMany maps each element to a sub-collection and then flattens all sub-collections into a single output sequence. It is a one-to-many mapping -- one input element can produce zero, one, or many output elements.

namespace ECommerce.Catalog;

public sealed record Category(string Name, IReadOnlyList<Product> Products);

// Select (wrong for this) -- produces IEnumerable<IReadOnlyList<Product>>
IEnumerable<IReadOnlyList<Product>> nestedProducts =
    categories.Select(c => c.Products);

// SelectMany -- flattens into IEnumerable<Product>
IEnumerable<Product> allProducts =
    categories.SelectMany(c => c.Products);

// Flatten order line items from all orders
IEnumerable<OrderLineItem> allLineItems =
    orders.SelectMany(o => o.LineItems);

// Flatten tags from a product -- tags are a collection property
IEnumerable<string> allTags =
    products.SelectMany(p => p.Tags);

The key mental model: Select produces one output per input (same length). SelectMany can produce any number of outputs per input (different length, flattened).


SelectMany with a Result Selector

The SelectMany overload that accepts a result selector lets you project the flattened sub-elements while keeping context from the parent element:

namespace ECommerce.Catalog;

// Flatten products while retaining the category name
IEnumerable<(string CategoryName, Product Product)> flat =
    categories.SelectMany(
        c => c.Products,
        (c, p) => (c.Name, p));

// Flatten order line items while retaining the order context
IEnumerable<(int OrderId, string CustomerId, OrderLineItem LineItem)> lineItemsWithContext =
    orders.SelectMany(
        o => o.LineItems,
        (o, li) => (o.Id, o.CustomerId, li));

// Build shipping tasks: one task per line item, per order
var shippingTasks =
    orders
        .Where(o => o.Status == OrderStatus.Processing)
        .SelectMany(
            o => o.LineItems,
            (o, li) => new ShippingTask(o.Id, o.ShippingAddress, li.Sku, li.Quantity));

The result selector pattern is significantly more readable than the equivalent Select + SelectMany chain because the parent context is available directly without needing a separate anonymous type to carry it forward.


Chaining Projections

Select calls can be chained freely. Because each Select is deferred, chaining them composes the transformations without allocating intermediate collections:

namespace ECommerce.Reporting;

public sealed record OrderLineItem(string Sku, int Quantity, decimal UnitPrice);
public sealed record Order(
    int Id,
    string CustomerId,
    IReadOnlyList<OrderLineItem> LineItems);

// Multi-step pipeline: no intermediate allocations until ToList()
var revenueByProduct =
    orders
        .SelectMany(o => o.LineItems)          // flatten to line items
        .Select(li => new                       // project to key+value
        {
            li.Sku,
            Revenue = li.Quantity * li.UnitPrice
        })
        .GroupBy(x => x.Sku)                   // group by product
        .Select(g => new                        // aggregate per product
        {
            Sku = g.Key,
            TotalRevenue = g.Sum(x => x.Revenue)
        })
        .OrderByDescending(x => x.TotalRevenue)
        .ToList();

This pipeline is evaluated in a single pass -- the SelectMany, Select, GroupBy, and final Select are all composed before ToList() triggers execution.


Anonymous Types vs Records: When to Use Each

Choosing between anonymous types and named records for projection targets depends on how far the projected type travels:

Use anonymous types when:

  • The projection is local to a single method
  • You need a temporary shape for a further LINQ operation (GroupBy key, intermediate join, etc.)
  • The type never crosses a method boundary or is never stored in a field

Use named records when:

  • The projected type is a method return value
  • The type represents a read model, DTO, or API response
  • You need XML documentation, validation, or serialization attributes
  • The type is used in tests, making the test assertions readable
namespace ECommerce.Queries;

// Anonymous type -- fine for internal pipeline use
var internalStep = orders.Select(o => new
{
    o.Id,
    LineCount = o.LineItems.Count
});

// Named record -- correct for a method return value or DTO
public sealed record OrderSummaryDto(int Id, string CustomerId, int LineCount, decimal Total);

IEnumerable<OrderSummaryDto> GetOrderSummaries(IEnumerable<Order> orders) =>
    orders.Select(o => new OrderSummaryDto(
        o.Id,
        o.CustomerId,
        o.LineItems.Count,
        o.LineItems.Sum(li => li.Quantity * li.UnitPrice)));

In a CQRS with Feature Slices architecture, each query handler defines its own response record. The Select projection that produces that record lives entirely within the handler -- no shared DTO bleeding across feature boundaries. This keeps projections focused and easy to evolve independently.


Practical: Projecting for a Read Model

A common real-world projection scenario is building a read model from domain entities. The Select operator lets you project to a flat, denormalized shape purpose-built for display:

namespace ECommerce.ReadModels;

public sealed record OrderDetailViewModel(
    int OrderId,
    string CustomerName,
    string CustomerEmail,
    IReadOnlyList<LineItemViewModel> LineItems,
    decimal Total,
    string StatusLabel);

public sealed record LineItemViewModel(
    string Sku,
    string ProductName,
    int Quantity,
    decimal UnitPrice,
    decimal LineTotal);

public static IEnumerable<OrderDetailViewModel> BuildOrderDetailViews(
    IEnumerable<Order> orders,
    IReadOnlyDictionary<string, Customer> customerById,
    IReadOnlyDictionary<string, string> productNameBySku)
{
    return orders.Select(o =>
    {
        var customer = customerById[o.CustomerId];
        var lineItems = o.LineItems
            .Select(li => new LineItemViewModel(
                li.Sku,
                productNameBySku.GetValueOrDefault(li.Sku, li.Sku),
                li.Quantity,
                li.UnitPrice,
                li.Quantity * li.UnitPrice))
            .ToList();

        return new OrderDetailViewModel(
            o.Id,
            customer.Name,
            customer.Email ?? string.Empty,
            lineItems,
            lineItems.Sum(l => l.LineTotal),
            o.Status.ToDisplayLabel());
    });
}

This pattern -- projecting domain objects to view models in a single, composable Select -- is common in Feature Slicing in C#, where each feature slice contains its own projection logic rather than relying on a global mapper.


Projecting for Semantic Search and AI Pipelines

Projection is not limited to UI concerns. When building AI-powered features, Select and SelectMany commonly extract text fields or build embedding input vectors from domain objects:

namespace ECommerce.Search;

// Extract text for embedding -- flatten title, description, and tags into one string
IEnumerable<(int ProductId, string EmbeddingInput)> embeddingInputs =
    products.Select(p => (
        p.Id,
        EmbeddingInput: string.Join(" ", new[]
        {
            p.Name,
            p.Description,
            string.Join(" ", p.Tags)
        })));

// SelectMany all searchable text tokens
IEnumerable<string> allSearchableTokens =
    products.SelectMany(p =>
        p.Tags.Append(p.Name).Append(p.CategoryName));

For a complete example of integrating LINQ-based pipelines with AI search, see Build a Semantic Search Engine with Semantic Kernel in C# -- LINQ projection is used throughout to shape the input data for embedding and retrieval.


Common Mistakes with Select and SelectMany

Mistake 1: Using Select when SelectMany is needed

namespace ECommerce.Catalog;

// Wrong -- IEnumerable<IReadOnlyList<Product>>, NOT IEnumerable<Product>
var nestedResult = categories.Select(c => c.Products);

// Correct -- IEnumerable<Product>
var flatResult = categories.SelectMany(c => c.Products);

The type signature tells you which you need: if Select would return IEnumerable<IEnumerable<T>>, use SelectMany instead.

Mistake 2: Calling ToList() inside Select (allocating per element)

namespace ECommerce.Orders;

// Expensive -- creates a new List<T> per element, forcing enumeration of each sub-collection
var eagerly = orders.Select(o => o.LineItems.ToList());

// Better -- keep lazy unless callers truly need mutable lists
var lazily = orders.Select(o => o.LineItems); // returns IReadOnlyList already

Mistake 3: Forgetting that Select is deferred

namespace ECommerce.Reporting;

var projectedQuery = orders.Select(o => ExpensiveTransform(o));

// ExpensiveTransform is called here (deferred)
foreach (var result in projectedQuery) { ... }

// ExpensiveTransform is called AGAIN if you iterate a second time
int count = projectedQuery.Count(); // second full pass through all transforms

If ExpensiveTransform has side effects or is computationally heavy, materialize with ToList() before iterating more than once.

The Factory Method Pattern pairs well here -- instead of embedding the construction logic inline in your Select lambda, encapsulate it in a factory method. This makes the projection testable in isolation and keeps the query chain readable.


Frequently Asked Questions

What is the difference between Select and SelectMany in LINQ?

Select is a one-to-one mapping -- each input element produces exactly one output element. The output sequence has the same length as the input. SelectMany is a one-to-many mapping -- each input element can produce zero, one, or many output elements, and all sub-sequences are flattened into a single output sequence. Use Select to transform elements; use SelectMany to flatten nested collections.

When should I use anonymous types vs named records in LINQ projections?

Use anonymous types for intermediate, local projections that stay within a single method (e.g., intermediate GroupBy keys, temporary pipeline shapes). Use named records when the projected type crosses a method boundary, represents a DTO or view model, is part of a public API, or needs to be tested by name. Named records also produce cleaner test assertion code than anonymous types.

What does the .NET 9 Index() method replace?

Index() replaces the pattern source.Select((item, i) => (i, item)). It returns a sequence of (int Index, T Item) value tuples for every element in the source. It makes intent explicit and removes the low-signal lambda from the call site. Available from .NET 9 / C# 13 onwards.

Does Select preserve order?

Yes. Select always preserves the input order -- the i-th output element is the projection of the i-th input element. This holds for LINQ to Objects. For LINQ to SQL/EF, the order of results depends on the generated query and whether an OrderBy clause is applied.

Can I nest Select and SelectMany in the same query?

Yes, and this is common for multi-level hierarchies. For a tree with Region -> Store -> Product, you can chain SelectMany(r => r.Stores).SelectMany(s => s.Products) to flatten to the leaf level, then Select to project each product into a view model. Each layer of nesting adds one SelectMany; the final projection is a Select.

Is SelectMany lazy?

Yes, both Select and SelectMany are deferred. No elements are enumerated until you iterate the result. Sub-collection access happens lazily per parent element as the caller walks the output sequence. If the sub-collection is expensive to produce (a database call, an I/O operation), be mindful of how many parent elements you process without materializing.

How do I project a flat sequence back into a nested structure?

Use GroupBy combined with Select to reconstruct hierarchy from a flat projection:

IEnumerable<Category> rebuilt =
    flatProducts
        .GroupBy(p => p.CategoryName)
        .Select(g => new Category(g.Key, g.ToList()));

This is the inverse of SelectMany -- SelectMany flattens; GroupBy + Select rebuilds.


Conclusion

LINQ projection in C# with Select and SelectMany covers the vast majority of data transformation needs in .NET applications. Select handles one-to-one transformations cleanly, including with index via the new .NET 9 Index() method. SelectMany flattens nested collections and, with its result selector overload, lets you carry parent context into the flattened output. Choosing named records over anonymous types for anything that crosses a method boundary, and being aware of deferred execution when composing chains, will keep your projection code both readable and efficient.

LINQ in C#: Complete Guide to Language Integrated Query (.NET 6-9)

Master LINQ in C# with this complete guide covering filtering, projection, ordering, grouping, joins, and every new operator added in .NET 6 through .NET 10.

ProjectXyz: Why I Started a Side Project (Part 1)

LINQ Filtering in C#: Where, Any, All, Contains, and OfType

Learn LINQ filtering in C# with Where, Any, All, Contains, and OfType. Covers compound predicates, null handling, and performance tips with .NET 6-9 examples.

An error has occurred. This application may no longer respond until reloaded. Reload