Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 1 · LINQ to Objects · Lesson 2 of 4

    Materialization with ToList: Snapshotting LINQ Results

    Watch

    Learning outcome

    By the end of this lesson, you should be able to explain why ToList() is often used at the end of a LINQ pipeline, what “materialization” means, and how it changes both behavior and performance. In interviews, the key idea is that a LINQ query is often just a recipe until it is enumerated. Calling ToList() executes that recipe immediately and stores the results in memory.

    Intuition

    LINQ to Objects is usually deferred: building the query does not read the data right away. Instead, the query is evaluated when you iterate it. ToList() changes that behavior by forcing the enumeration now and copying the results into a list.

    That copy acts like a snapshot. Adding, removing or replacing entries in the source collection does not change the materialized list. This is a shallow copy: for reference-type elements, both collections can still refer to the same mutable objects. Mutating one of those objects remains visible through either reference. This is useful when you need stable data, want to iterate multiple times without re-running the query, or want to detach from a mutable source.

    Deep dive

    A deferred LINQ query can be re-evaluated every time you enumerate it. That means the query may observe new source values, produce new results, or repeat expensive work. ToList() prevents that by enumerating once and storing the output.

    This matters for three common reasons:

    1. Snapshot semantics: the list captures the selected entries at the moment ToList() runs; it does not deep-clone reference-type objects.
    2. Repeated access: later loops over the list are cheap compared with rerunning the query.
    3. Side effects and cost: if the query includes expensive projections or reads from a mutable source, materialization avoids repeating that work.

    Use this distinction carefully in interviews: this lesson is about LINQ to Objects. A database provider may translate a query differently, and ToList() there often means “send the query to the database now and pull the rows into memory.” The snapshot idea still applies, but the execution source is different.

    Failure modes

    A common mistake is assuming a deferred LINQ query already stores its results. Simply enumerating it does not cache those results for later use. Materialize explicitly when you need a reusable stored result set.

    Another mistake is overusing ToList() too early. Materializing too soon can increase memory usage and remove opportunities for filtering or streaming.

    A third mistake is forgetting that ToList() freezes the results, not the original source. If the source changes later, the list does not magically track it.

    Interview edge cases: choose the storage boundary

    The practice examples use LINQ to Objects and modern C# (C# 9 or later for top-level programs), with the usual System, System.Collections.Generic and System.Linq imports. Unless a question says otherwise, inputs are non-null and are not modified concurrently.

    • When only a bounded prefix is needed, put the limit before materialization: source.Where(Matches).Take(20).ToList() stores at most twenty matches. If fewer match, finding them can still require reading the whole input. Calling ToList before the limit can store far more data; materializing an endless sequence cannot complete.
    • Materialize once and share that result when read-only consumers must see the same set. Calling query.ToList() separately for each consumer evaluates the original query separately.
    • Materialization is not recursive. An outer ToList can store deferred child queries without evaluating them. Materialize each inner query inside the projection too when stable inner membership is required.
    • The resulting list can still be edited and its referenced objects can still change. Materialization does not establish immutability or safe concurrent access. Choose ownership, immutable data or synchronization to match the actual requirement.
    • An assignment receives the new list only after ToList returns successfully. If a selector throws, a previously null destination remains null; it is not assigned a partial list. Earlier side effects are not rolled back.
    • A stored result is not a change subscription. If the business view must include later source updates, rerun the query at a deliberate refresh boundary or implement an explicit update mechanism.

    Interview drill

    Explain the difference between these two patterns:

    • Store an IEnumerable<T> query and enumerate it twice.
    • Call ToList() once and enumerate the list twice.

    A strong answer should mention that the first pattern may repeat query evaluation, while the second creates a one-time snapshot.

    Follow-up question: why might ToList() make debugging easier when a source collection is mutated after the query is defined?

    Revision checklist

    • [ ] I can define materialization in one sentence.
    • [ ] I can explain that ToList() forces immediate execution.
    • [ ] I can describe snapshot semantics clearly.
    • [ ] I can explain why repeated enumeration can repeat work.
    • [ ] I can distinguish LINQ to Objects from provider-translated queries.
    • [ ] I know when ToList() is helpful and when it is unnecessary.

    Production code

    Use ToList() when you need a stable, in-memory result and you expect to traverse it more than once. The example below shows a deferred query observing source changes until it is materialized, after which the list stays fixed.

    Code walkthrough

    The query evenSquaresQuery is not a list yet. It is a deferred pipeline that reads from numbers when enumerated.

    The first WriteLine forces enumeration, so the output reflects the original values 2 and 4, transformed into 4 and 16.

    ToList() then materializes those results into snapshot. After that, the source list changes: one element is updated and one is added. The deferred query sees those new values the next time it runs.

    The snapshot list does not change, because it already holds copied results. That is the core interview takeaway: ToList() gives you a stable snapshot and stops repeated reevaluation of the original query.

    Output: ToList creates a snapshot and stops repeated reevaluation

    Before changing source:
    4, 16
    After changing source, deferred query:
    64, 16, 36
    Materialized snapshot:
    4, 16

    Executable code examples

    ToList creates a snapshot and stops repeated reevaluation

    Program.cs

    C#Runs
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    var numbers = new List<int> { 1, 2, 3, 4 };
    
    IEnumerable<int> evenSquaresQuery = numbers
        .Where(n => n % 2 == 0)
        .Select(n => n * n);
    
    Console.WriteLine("Before changing source:");
    Console.WriteLine(string.Join(", ", evenSquaresQuery));
    
    var snapshot = evenSquaresQuery.ToList();
    
    numbers.Add(6);
    numbers[1] = 8;
    
    Console.WriteLine("After changing source, deferred query:");
    Console.WriteLine(string.Join(", ", evenSquaresQuery));
    
    Console.WriteLine("Materialized snapshot:");
    Console.WriteLine(string.Join(", ", snapshot));

    Optional video

    Gavin Lon / freeCodeCamp.org · Watch 2:47:02–2:52:31 for conversion operators including ToList and ToArray. Watching is optional.

    Watch on YouTube.

    A new folder of shortcuts

    For reference-type elements, picture ToList() as making a new folder containing shortcuts to the selected documents. Adding or removing shortcuts in the original folder does not change the new folder. Editing a shared document is still visible through either shortcut.

    That is the difference between copying collection entries and deep-copying objects. With integer results, the new list simply stores the integer values calculated at that moment. Neither kind of list updates itself when the original query would later produce different results.

    Choose the materialization boundary

    • Need one bounded result for several readers? Call ToList() once, then reuse that list.
    • Need at most 20 matches? Filter, then Take(20), then ToList().
    • Need later source changes? Re-evaluate at an explicit refresh boundary.
    • Reference objects remain shared unless your projection deliberately copies their data.
    • An outer ToList() does not recursively evaluate nested queries.
    • A materialized List<T> is still mutable; it is not automatically thread-safe.
    • If ToList() throws, its assignment does not receive a partial returned list. Earlier side effects are not rolled back.
    • Avoid materializing an unbounded sequence; choose a meaningful limit first.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in