Fix Common Errors

Fix "Collection Was Modified During Enumeration"

Stop 'Collection Was Modified During Enumeration' errors in C# with four proven solutions: ToList(), RemoveAll(), backward loops, and ConcurrentCollections.

The "collection was modified during enumeration" C# error hits developers regularly, usually in the middle of production when a list is being looped over and items are being removed at the same time. It's a classic concurrency issue that seems simple to understand — you can't modify something while you're iterating through it — but the solutions aren't always obvious, especially under time pressure. This post covers exactly why it happens, four solid fixes, and how to prevent it going forward.

The error occurs because .NET collections track their internal state during enumeration. When you use foreach or call GetEnumerator(), the collection creates an enumerator that remembers a "version" of the collection at the start of iteration. The moment anything structural changes — an add, remove, or clear operation — that version number changes. If the enumerator notices the mismatch, it throws InvalidOperationException with that exact message. The framework does this to protect you from undefined behavior. Without this check, you'd silently skip elements, process the same item twice, or crash in less obvious ways.

Why "Collection Was Modified During Enumeration" Happens in C#

The bug almost never occurs in toy examples. It appears in production when business logic is layered: you're iterating to find items that meet a condition, and when you find them, you modify the collection. Here's a realistic example:

public class NotificationQueue
{
    private List<Notification> _notifications = new();

    public void ProcessExpiredNotifications()
    {
        foreach (var notification in _notifications)
        {
            if (notification.ExpiresAt < DateTime.UtcNow)
            {
                _notifications.Remove(notification);  // InvalidOperationException
            }
        }
    }
}

This looks reasonable: iterate, check expiration, remove. But the moment Remove() is called, the enumerator's version check fails. The exception is thrown, the loop aborts, and some notifications never get processed.

This error is NOT a race condition or threading issue in the traditional sense. It happens in single-threaded code. The check is there because modifying a collection during enumeration leads to unpredictable iteration order and silent data loss.

Solution 1: ToList() — The Simplest Approach

Create a snapshot of the collection before iterating. Since you're iterating over a copy, the original list can be modified safely:

public void ProcessExpiredNotifications()
{
    // Iterate over a copy; modify the original
    foreach (var notification in _notifications.ToList())
    {
        if (notification.ExpiresAt < DateTime.UtcNow)
        {
            _notifications.Remove(notification);  // No error
        }
    }
}

Pros: Easy to understand, one-liner, works everywhere.

Cons: Memory overhead — ToList() allocates a new list with a copy of every element. For large collections, this matters.

When to use: Fewer than a few thousand items, or when simplicity is worth the cost.

Solution 2: RemoveAll() — The Functional Way

Instead of manually looping and removing, use RemoveAll(), which handles enumeration internally and safely:

public void ProcessExpiredNotifications()
{
    _notifications.RemoveAll(n => n.ExpiresAt < DateTime.UtcNow);
}

This is genuinely elegant. RemoveAll() iterates using its own internal enumerator, not a public one, so it can modify the collection without triggering the version check. It's also more concise and often faster.

Pros: No temporary allocation, cleaner code, single responsibility.

Cons: Only works if your entire operation is "remove items matching a condition." Complex logic may not fit this pattern.

When to use: When your logic is a simple predicate; when memory pressure or scale matters.

Solution 3: Iterate Backwards — The Index-Based Approach

If you need a traditional loop with complex logic, iterate from the end to the beginning. Removing items at the end doesn't shift indices of items you haven't checked yet:

public void ProcessExpiredNotifications()
{
    for (int i = _notifications.Count - 1; i >= 0; i--)
    {
        if (_notifications[i].ExpiresAt < DateTime.UtcNow)
        {
            _notifications.RemoveAt(i);  // Safe; doesn't affect remaining indices
        }
    }
}

Pros: No allocations, works with complex logic, efficient.

Cons: Index-based loops are less readable; you must remember to go backwards or it breaks silently.

When to use: When performance is critical or logic is complex; when you're already in a for loop rather than foreach.

Backward iteration is especially useful in UI code or game loops where you might be removing items from a collection of active objects every frame.

Solution 4: ConcurrentCollections — For Concurrent Access

If multiple threads are accessing the collection, neither ToList() nor backward iteration solves the real problem. Use thread-safe collections:

using System.Collections.Concurrent;

public class ThreadSafeNotificationQueue
{
    private ConcurrentBag<Notification> _notifications = new();

    public void ProcessExpiredNotifications()
    {
        var expired = _notifications
            .Where(n => n.ExpiresAt < DateTime.UtcNow)
            .ToList();

        foreach (var notification in expired)
        {
            _notifications.TryTake(out _);  // Remove safely
        }
    }
}

Alternatively, if you need a list-like interface with safe enumeration, use ConcurrentDictionary keyed by ID or lock the collection:

private readonly object _lock = new();
private List<Notification> _notifications = new();

public void ProcessExpiredNotifications()
{
    lock (_lock)
    {
        _notifications.RemoveAll(n => n.ExpiresAt < DateTime.UtcNow);
    }
}

Pros: Thread-safe, no silent data corruption.

Cons: Locking has performance costs; concurrent collections have different trade-offs per type.

When to use: Any code accessed by multiple threads.

Debugging and Prevention

When this error hits in production, check your stack traces for where the enumerator failed. The exception message isn't always descriptive, but the stack trace will show the exact line in your code. Track these errors with error tracking best practices so you catch patterns (e.g., "always fails in the background job queue at 2 AM when backlog is large").

The best prevention is code review and unit tests. Write a test that mutates the collection during iteration — it should fail before production:

[Test]
public void ProcessExpiredNotifications_WithExpiredItems_RemovesAll()
{
    var queue = new NotificationQueue();
    queue.Add(new Notification { ExpiresAt = DateTime.UtcNow.AddHours(-1) });
    queue.Add(new Notification { ExpiresAt = DateTime.UtcNow.AddHours(1) });

    queue.ProcessExpiredNotifications();  // Should not throw

    Assert.That(queue.Count, Is.EqualTo(1));
}

Use a linter or static analyzer (like Roslyn analyzers) to flag foreach loops with Remove() calls. Many teams add custom rules to catch this pattern automatically.

Which Solution to Pick

  • Small collections, simple code: ToList()
  • Removing items by condition: RemoveAll()
  • Complex logic, performance matters: Backward for loop
  • Multithreaded code: ConcurrentCollections or locks

Most teams default to ToList() for clarity, and that's fine. Don't over-optimize unless profiling shows a real bottleneck. The error is easy to fix once you recognize it; the hard part is debugging production errors before they cascade.

If you're running .NET applications in production and want visibility into errors like this the moment they happen, LightTrace captures full stack traces, breadcrumbs, and context so your team can find the root cause quickly. It works with any Sentry SDK — no code changes needed.

Start tracking errors in minutes

Start tracking .NET errors with LightTrace — free, no credit card required.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.