Mid-level 4 min · March 06, 2026

C# List.Contains O(n²) Trap — Checkout Died at 500 Items

List.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Arrays: fixed-size contiguous storage (int[]), O(1) index access, cannot grow, stored inline on stack or heap depending on context
  • List: dynamic array that grows automatically (default capacity 4, doubles on resize), O(1) amortised Add, O(1) index access
  • Dictionary: hash table for O(1) key-based lookups, requires GetHashCode() and Equals() implementation for custom keys
  • Performance: Dictionary lookup ~100ns, List.Contains O(n) ~1,000ns for 10K items — 10x slower
  • Production trap: Using List where O(n) Contains is called in loop (O(n²) performance) instead of Dictionary (O(n))
  • Biggest mistake: Modifying collection while iterating with foreach (InvalidOperationException) — use for loop backward or ToList() copy
Plain-English First

Imagine you're organising a birthday party. A single variable is like holding one balloon in your hand — great for one thing. An array is like a balloon rack with fixed, numbered slots — you decide upfront you need exactly 10 balloons, and each slot has a number starting at zero. A List is like a stretchy gift bag — you can keep stuffing more presents in without deciding the size in advance. A Dictionary is like a labelled coat-check counter — instead of a number, each item has a unique name tag (like 'Alice's coat') so you can grab exactly the right one instantly.

Every real app — from a shopping cart to a leaderboard to a contact book — needs to store more than one piece of data at a time. The moment you move beyond a single variable, you need a structure that can hold a group of values and let you work with them together. That's exactly what arrays and collections do, and they are arguably the most-used tools in any C# developer's daily work. Knowing them well separates a developer who struggles with basic data wrangling from one who writes clean, confident code.

Before collections existed, developers had to manually juggle groups of related data — declaring ten separate variables for ten students, copying data into new arrays whenever the size changed, writing fragile code that broke if you added one extra item. Arrays gave us numbered slots in memory. The Collections namespace in .NET went further, giving us dynamic, purpose-built containers like List, Dictionary, Queue, and Stack that handle growth, lookup, and ordering automatically.

By the end you'll be able to declare and use arrays, create and manipulate List<T> and Dictionary<TKey, TValue>, iterate over any collection with a foreach loop, choose the right container for a given problem, and avoid the three classic mistakes that trip up almost every beginner.

C# Arrays — Fixed-Size Storage With Numbered Slots

An array is the simplest way to store multiple values of the same type. Think of it as a row of labelled lockers in a school corridor. You decide how many lockers you need when you build the corridor — that number never changes. Each locker has a number starting at zero (not one — this trips people up constantly). Locker zero is the first one.

You declare an array by writing the type, square brackets, a name, and then using new to create it with a fixed size. Once created, every slot is automatically filled with the default value for that type — zero for numbers, null for strings.

Arrays are the right choice when you know exactly how many items you'll have and that number won't change — days of the week, months of the year, RGB colour channels. They are blazing fast because the computer lays them out in a straight line in memory. But if the size might change — use a List instead, which we'll cover next.

The .Length property tells you how many slots exist. Accessing a slot that doesn't exist (like index 10 in a 5-slot array) throws an IndexOutOfRangeException — one of the most common crashes beginners see.

io/thecodeforge/collections/ArrayBasics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace io.thecodeforge.collections
{
    class ArrayBasics
    {
        static void Main()
        {
            // 1. Fixed-size allocation for weekdays
            string[] weekdays = new string[5];
            weekdays[0] = "Monday";
            weekdays[1] = "Tuesday";
            weekdays[2] = "Wednesday";
            weekdays[3] = "Thursday";
            weekdays[4] = "Friday";

            // 2. Shorthand syntax
            int[] highScores = { 9800, 7650, 6200, 5100, 3400 };

            Console.WriteLine($"First weekday: {weekdays[0]}");
            Console.WriteLine($"Top score: {highScores[0]}");

            Console.WriteLine("\n--- High Score Leaderboard ---");
            for (int i = 0; i < highScores.Length; i++)
            {
                Console.WriteLine($"#{i + 1}: {highScores[i]}");
            }
        }
    }
}
Watch Out: Indexes Start at Zero, Always
The first element is always at index 0, not 1. A 5-element array has valid indexes 0 through 4. Trying to access index 5 throws an IndexOutOfRangeException at runtime — the compiler won't catch this for you. Always use array.Length - 1 if you need the last item, or just use array[^1] in modern C# (version 8+).
Production Insight
Array access arr[i] is a CPU-level address calculation — base + (i * element_size) — O(1) constant time.
Arrays are allocated contiguously in memory, making them cache-friendly for sequential iteration. 10x faster than LinkedList.
Rule: Use arrays for fixed-size, performance-critical buffers (network packets, image pixels). For variable-length data, use List<T>; its internal array provides same O(1) access.
Key Takeaway
Arrays are fixed-size and zero-indexed — slot 0 is first, slot (Length-1) is last. Use them for data that never changes in size.
Array access is O(1) and cache-friendly, making them the fastest collection for positional access.
Rule: For fixed-size data (months, days, matrix rows), arrays are the right default.

List — The Dynamic Array That Grows With You

The moment you need a collection whose size isn't fixed — a shopping cart that can have any number of items, a list of players that join and leave — an array becomes a headache. You'd have to create a new, bigger array and copy everything over every time you wanted to add an item. That's exactly the problem List<T> solves.

List<T> is a generic collection. The T is a placeholder for the type you want to store — List<string> stores strings, List<int> stores integers. Think of it as an array with superpowers: it resizes itself automatically, gives you .Add(), .Remove(), .Contains(), .Count, and dozens of other useful methods out of the box.

Under the hood, a List actually is backed by an array — it just handles all the resizing work for you. When it runs out of room, it silently creates a new internal array twice the size and copies everything across. You never have to think about this, but it's good to know so you understand why arrays are marginally faster for fixed data.

Use List<T> as your default collection whenever the size might change or you don't know it upfront. It's the workhorse of C# development — you'll use it in virtually every project you write.

io/thecodeforge/collections/ListBasics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;
using System.Collections.Generic;

namespace io.thecodeforge.collections
{
    class ListBasics
    {
        static void Main()
        {
            // 1. Dynamic list for a shopping cart
            List<string> shoppingCart = new List<string>();
            shoppingCart.Add("Apples");
            shoppingCart.Add("Bread");
            shoppingCart.Add("Milk");

            Console.WriteLine($"Items in cart: {shoppingCart.Count}");

            // 2. Removing and checking existence
            shoppingCart.Remove("Bread");
            bool hasMilk = shoppingCart.Contains("Milk");

            // 3. Sorting in-place
            List<int> scores = new List<int> { 90, 10, 50, 30 };
            scores.Sort();

            Console.WriteLine("Sorted scores:");
            scores.ForEach(s => Console.Write(s + " "));
        }
    }
}
Pro Tip: Use .Count, Not .Length on a List
Arrays use .Length. Lists use .Count. They do the same thing — tell you how many items are stored — but using the wrong one is a compiler error that catches out almost every beginner on their first day with collections. The rule is simple: if it ends in [] it's an array, use .Length. If it's a List<T>, use .Count.
Production Insight
List.Add is O(1) amortised — most adds are fast (just write to next slot), occasionally O(n) when resizing (copies entire array).
Default capacity is 4. If you know final size, pass capacity to constructor: new List<T>(10000) — eliminates resizes and copies.
Rule: For large lists (>10,000 elements), pre-size the List to avoid resizing overhead and GC pressure.
Key Takeaway
List<T> is your everyday workhorse — it resizes itself, supports Add/Remove/Contains, and uses .Count (not .Length) to tell you how many items it holds.
Under the hood, it's an array that doubles in size when full. Pre-size it when you know the approximate count.
Rule: If you need fast membership tests (Contains), List is O(n). use HashSet<T> for O(1) lookups.

Dictionary — Lightning-Fast Lookup by Name

Sometimes a numbered index isn't the right way to identify data. You don't want player stats at index 3 — you want them by player name. You don't want a country's capital at index 47 — you want it by the country's name. That's what Dictionary<TKey, TValue> is for.

A dictionary stores key-value pairs. Every item has a unique key (like a name, an ID, a code) and a value (the data attached to that key). Think of a real physical dictionary: you look up a word (the key) and get the definition (the value) instantly — you don't have to read every page to find it.

Lookup in a dictionary is extremely fast — essentially instant regardless of how many items are stored — because .NET uses a technique called hashing internally. Compare this to searching through a List where, in the worst case, you have to check every single item.

Keys must be unique. You can't have two entries with the key "Alice" — the second add will throw an exception. Values can be duplicated — two players can have the same score. Use ContainsKey() before adding if you're not sure whether the key exists yet.

Use a Dictionary whenever you need to look things up by a meaningful name or ID rather than a position number.

io/thecodeforge/collections/DictionaryBasics.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using System.Collections.Generic;

namespace io.thecodeforge.collections
{
    class DictionaryBasics
    {
        static void Main()
        {
            // 1. Storing prices by product SKU
            var priceLookup = new Dictionary<string, decimal>
            {
                { "APP-01", 1.99m },
                { "BRD-02", 2.50m }
            };

            // 2. Safe retrieval with TryGetValue
            string targetSku = "MLK-03";
            if (priceLookup.TryGetValue(targetSku, out decimal price))
            {
                Console.WriteLine($"Price of {targetSku}: {price}");
            }
            else
            {
                Console.WriteLine("Product not found.");
            }

            // 3. Iterating over keys and values
            foreach (var entry in priceLookup)
            {
                Console.WriteLine($"SKU: {entry.Key}, Price: {entry.Value}");
            }
        }
    }
}
Watch Out: Dictionaries Don't Guarantee Order
A Dictionary<TKey, TValue> does not store items in the order you added them. If you need items in insertion order, use a List<T> or in .NET 5+ you can rely on Dictionary maintaining insertion order as an implementation detail — but never depend on it as a guarantee. If ordered key-value pairs matter, use SortedDictionary<TKey, TValue> instead.
Production Insight
Dictionary lookup is O(1) average — but that depends on good GetHashCode() distribution. Poor hash codes degrade to O(n).
Never use Dictionary with mutable keys. If key object changes after insertion, GetHashCode changes, and the key becomes unfindable.
Rule: For custom key types, override GetHashCode() and Equals(). Use immutable keys (string, int, Guid) where possible.
Key Takeaway
Dictionary<TKey, TValue> gives you near-instant lookup by a meaningful key — use TryGetValue() instead of the indexer to avoid crashes on missing keys.
Keys must be unique; values can duplicate. For membership tests without values, use HashSet<T> (lightweight).
Rule: If you need fast key-based lookups, Dictionary is O(1). List is O(n). Choose based on access pattern.
● Production incidentPOST-MORTEMseverity: high

The O(n²) List.Contains That Took Down Checkout

Symptom
Checkout service responded normally for small carts (under 20 items). At 200+ items, latency climbed exponentially. At 500 items, checkout took 8 seconds and often timed out. CPU on the checkout service was high but not maxed — the bottleneck was memory access patterns in List.Contains (linear scan). Database showed no slow queries. Cache hit rate was normal.
Assumption
The team assumed List.Contains was O(1) like Dictionary. They didn't know List is a linear scan — O(n). They also assumed their cart validation logic was efficient because they'd load-tested with 10 items. They never tested with 500 items because their test harness defaulted to 10.
Root cause
The checkout validation loop executed if (shoppingCart.Contains(productId)) for each new item being added to the cart. shoppingCart was a List<int>. List.Contains calls Array.IndexOf internally — a linear scan O(n). For a cart with N items, the total complexity of adding N items with duplicate check was Σ O(i) from i=1 to N = O(n²). For N=500, that's ~125,000 operations. For N=1000, that's ~500,000 operations — a quadratic blowup. The team refactored to use HashSet<int> for the duplicate check, which is O(1) average, dropping total complexity to O(n). After the fix, 500-item checkout took 100ms.
Fix
1. Changed duplicate check from List<int> to HashSet<int> for the validity pass. 2. Kept the main cart as List<int> because order matters for display, but the duplicate check used cartSet = new HashSet<int>(cartList) for O(n) build once, O(1) checks. 3. Added performance tests with 1, 10, 100, 500, 1000 items to catch regressions. 4. Added an ICollection<T> abstraction that selects HashSet for membership tests, List for ordering. 5. Documented the performance characteristics of each collection at the call site: // O(n) lookup, use HashSet if called repeatedly.
Key lesson
  • List.Contains is O(n) — linear scan. Dictionary.ContainsKey and HashSet.Contains are O(1) average.
  • O(n²) algorithms are invisible in small tests (10 items → 100 operations) but catastrophic at scale (1000 items → 1,000,000 operations).
  • Always check the complexity of collection operations. List<T> is not a magic bullet — it's just an array wrapper.
  • When you need fast lookups by key, use Dictionary<TKey,TValue>. When you need fast membership tests without values, use HashSet<T> (O(1) Add, Remove, Contains).
Production debug guideSymptom → Action mapping for common collection failures in production .NET applications.5 entries
Symptom · 01
ArgumentOutOfRangeException when accessing list by index
Fix
Index is >= list.Count or negative. Add debug: Console.WriteLine($'Index: {idx}, Count: {list.Count}');. Use TryGetValue for dictionaries. For lists, check if (idx < list.Count && idx >= 0) before access.
Symptom · 02
KeyNotFoundException in Dictionary — key missing
Fix
You used indexer dict[key] without checking existence. Replace with dict.TryGetValue(key, out var value). Or check if (dict.ContainsKey(key)). Never assume the key exists.
Symptom · 03
Performance slow — CPU high in collection operations
Fix
Likely O(n²) nested loops with List.Contains. Profile with dotMemory or BenchmarkDotNet. Replace with HashSet or Dictionary for lookups. Check for foreach nested inside another foreach over same collection.
Symptom · 04
InvalidOperationException: Collection was modified during enumeration
Fix
You're calling .Remove() or .Add() inside a foreach loop. Fix: iterate over .ToList() copy: foreach (var item in list.ToList()) { list.Remove(item); }. Or use for loop backward: for (int i = list.Count - 1; i >= 0; i--) { list.RemoveAt(i); }
Symptom · 05
Duplicate key added to Dictionary — ArgumentException
Fix
You called dict.Add(key, value) without checking if key exists. Use indexer dict[key] = value (overwrites) or check if (!dict.ContainsKey(key)) dict.Add(...);
★ C# Collection Debug Cheat SheetFast diagnostics for collection issues in production .NET applications.
IndexOutOfRangeException — accessing list index
Immediate action
Log list count and index before access
Commands
Console.WriteLine($"Attempting index {idx} on list of count {list.Count}");
Debug.Assert(idx >= 0 && idx < list.Count, "Index out of range");
Fix now
Use pattern: if (idx >= 0 && idx < list.Count) { var item = list[idx]; } else { / handle error / }
KeyNotFoundException in dictionary+
Immediate action
Check if key exists before access
Commands
if (dict.ContainsKey(key)) { var value = dict[key]; }
dict.TryGetValue(key, out var value);
Fix now
Replace var x = dict[key] with if (dict.TryGetValue(key, out var x)) { / use x / }
Performance - slow list lookup+
Immediate action
Check if nested loops call Contains on same list
Commands
grep -n 'Contains' src/ | grep -v 'HashSet'
dotTrace or dotMemory to profile hot path
Fix now
Replace List.Contains with HashSet.Contains for membership tests. Build HashSet once: var hashSet = new HashSet<T>(list);
Collection modified during foreach+
Immediate action
Find the Add/Remove call inside the foreach block
Commands
grep -A10 'foreach' src/ | grep -E 'Add|Remove|Clear'
Console.WriteLine('Modification at ' + Environment.StackTrace);
Fix now
Iterate over copy: foreach (var item in list.ToList()) { list.Remove(item); }
Duplicate key in Dictionary - ArgumentException+
Immediate action
Check if Add is called without checking ContainsKey
Commands
grep -n '\.Add(' src/ | grep -v 'ContainsKey'
Console.WriteLine($'Key {key} already exists with value {dict[key]}');
Fix now
Replace dict.Add(key, value) with dict[key] = value (overwrites) or add check: if (!dict.ContainsKey(key)) dict.Add(key, value);
C# Collection Performance Characteristics
OperationArrayList<T>Dictionary<K,V>HashSet<T>LinkedList<T>
Access by indexO(1)O(1)N/AN/AO(n)
Add to endN/A (fixed size)O(1) amortisedO(1) amortisedO(1)O(1)
Insert at middleN/AO(n)N/AN/AO(1) if position known
Contains (value lookup)O(n)O(n)O(1)* (key)O(1)* (value)O(n)
Remove by valueN/AO(n)O(1) by keyO(1)O(n)
Remove by indexN/AO(n)N/AN/AO(1) if node known
Memory per elementLowest (contiguous)Low (contiguous)Higher (buckets+entries)Higher (buckets+slots)High (two pointers per node)
Iteration orderDeclaration orderInsertion orderNot guaranteed (.NET 5+ insertion, but don't rely)Not guaranteedInsertion order

Key takeaways

1
Arrays are fixed-size and zero-indexed
slot 0 is first, slot (Length-1) is last. Use them for data that never changes in size.
2
List<T> is your everyday workhorse
it resizes itself, supports Add/Remove/Contains, and uses .Count (not .Length) to tell you how many items it holds.
3
Dictionary<TKey, TValue> gives you near-instant lookup by a meaningful key
use TryGetValue() instead of the indexer to avoid crashes on missing keys.
4
Never add to or remove from a List<T> inside a foreach loop
it throws at runtime. Collect changes and apply them after the loop or use RemoveAll.
5
List.Contains is O(n)
if you call Contains in a loop, you get O(n²) performance. Use HashSet<T> or Dictionary<TKey,TValue> for O(1) lookups.

Common mistakes to avoid

5 patterns
×

Off-by-one index errors on arrays and lists

Symptom
IndexOutOfRangeException/ArgumentOutOfRangeException at runtime. The code compiles, but crashes when accessing index equal to Count.
Fix
Remember arrays and lists are zero-indexed. A 5-element array has valid indexes 0..4. Use array[^1] for last element (C# 8+), or array[array.Length - 1]. In loops: for (int i = 0; i < list.Count; i++) (not i <= list.Count).
×

Adding a duplicate key to a Dictionary

Symptom
ArgumentException: An item with the same key has already been added. The exception is thrown on .Add() call.
Fix
Always check first with if (!dict.ContainsKey(key)) dict.Add(key, value); or use the indexer dict[key] = value which safely overwrites instead of throwing. Choose based on desired behavior (add vs update).
×

Modifying a List while iterating over it with foreach

Symptom
InvalidOperationException: Collection was modified; enumeration operation may not execute. Occurs during next iteration after modification.
Fix
Iterate over a copy: foreach (var item in list.ToList()) { list.Remove(item); }. Or iterate backward with for loop: for (int i = list.Count - 1; i >= 0; i--) { list.RemoveAt(i); }. Or use RemoveAll(predicate).
×

Using List.Contains in a loop (O(n²) performance)

Symptom
Performance degrades quadratically as collection grows. 1000 items → 1,000,000 operations. Latency spikes from milliseconds to seconds.
Fix
If you need fast membership tests, use HashSet<T>: var hashSet = new HashSet<T>(list); if (hashSet.Contains(item)) { ... }. HashSet.Contains is O(1) average. For key-value lookups, use Dictionary<TKey,TValue>.
×

Assuming Dictionary preserves insertion order

Symptom
Iteration order does not match insertion order. Bugs appear only in certain .NET versions (old: arbitrary, .NET5+: insertion order as implementation detail but not guaranteed).
Fix
Do not rely on Dictionary order. Use SortedDictionary<TKey,TValue> for ordered by key, or List<KeyValuePair<TKey,TValue>> for insertion-order preservation. If you need both lookup and order, maintain both structures.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the internal behavior of List when it exceeds its current cap...
Q02SENIOR
Why does a Dictionary lookup take O(1) time? Discuss the role of GetHash...
Q03SENIOR
What is the difference between IEnumerable, ICollection, and IList...
Q04SENIOR
How do you implement a Two-Sum problem (LeetCode standard) efficiently u...
Q01 of 04SENIOR

Explain the internal behavior of List when it exceeds its current capacity. What is the time complexity of adding an element (Amortized O(1))?

ANSWER
List<T> internally uses an array. When you call Add and the internal array is full, List creates a new array with double the capacity (growth factor 2), copies all existing elements using Array.Copy, and then adds the new element. The copy operation is O(N) for that single Add, but it happens only when capacity is exceeded — about O(log N) times over N additions. The total cost of all copies over N adds is O(N), so the average (amortized) cost per Add is O(1). Initial capacity defaults to 4. If you know the final size, pre-size the List in the constructor to avoid resizes entirely: new List<T>(10000). Capacity is always at least the number of elements stored, accessed via .Capacity. After adding, you can call .TrimExcess() to release unused capacity, but this causes another O(N) copy.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between an array and a List in C#?
02
How do I avoid a KeyNotFoundException when reading from a C# Dictionary?
03
Can a C# List hold different types — like a mix of strings and numbers?
04
Which C# collection is best for implementing a First-In-First-Out (FIFO) queue?
05
How does HashSet differ from Dictionary?
🔥

That's C# Basics. Mark it forged?

4 min read · try the examples if you haven't

Previous
Methods and Parameters in C#
5 / 11 · C# Basics
Next
Strings in C#