TL;DR

  • Picking the right collection can dramatically speed up your code
  • Use List<T> for ordered data with indexed access, but avoid for frequent searches
  • Choose Dictionary<TKey, TValue> for O(1) lookups by key and mapping relationships
  • Implement HashSet<T> to enforce uniqueness and perform fast membership tests
  • Apply Queue<T> for FIFO processing and Stack<T> for LIFO operations
  • Select LinkedList<T> only when frequently inserting/removing from the middle with node references
  • Pick sorted collections (SortedDictionary<TKey, TValue>, SortedList<TKey, TValue>) for range operations on ordered data
  • Use ObservableCollection<T> exclusively for UI data binding scenarios
  • Implement ConcurrentDictionary<TKey, TValue> for thread-safe operations without manual synchronization
  • Consider memory overhead, thread safety, and ordering guarantees alongside time complexity

Picking the right collection in C# can be the difference between a fast app and a slow one. I’ve fixed production systems that were crawling because someone used a List<T> for lookups instead of a Dictionary<TKey, TValue>. I’ve also seen memory usage go through the roof when devs chose LinkedList<T> thinking it would always be faster than List<T>.

Here I’ll show you the 10 most important C# collections with practical examples, performance details, and when to use each one. When you’re done reading, you’ll know which collection works best for your situation, and which ones to stay away from.

Why Collection Choice Matters

Why does this even matter? In .NET, collections aren’t just containers - they’re key architectural decisions that impact memory usage, CPU performance, and thread safety. Picking the wrong one can turn a lightning-fast operation into a slow crawler, or worse, create weird bugs that only show up when your system is under heavy load.

The key factors we’ll examine for each collection:

  • Time complexity for common operations
  • Memory overhead and allocation patterns
  • Thread safety characteristics
  • Ordering guarantees (insertion order, sorted, etc.)
  • Real-world use cases where each collection shines

1. List - The Swiss Army Knife

List<T> is probably the first collection you learned, and for good reason. It’s a dynamic array that grows as needed, maintaining insertion order and providing indexed access.

// Modern C# initialization
List<string> usernames = ["admin", "guest", "developer"];

// Adding items
usernames.Add("newuser");
usernames.AddRange(["user1", "user2"]);

// Indexed access - O(1)
string firstUser = usernames[0];

// Search - O(n)
int index = usernames.IndexOf("admin");
bool hasGuest = usernames.Contains("guest");

How It Performs:

  • Access by index: O(1)
  • Search: O(n)
  • Insert at end: O(1) amortized
  • Insert at beginning: O(n)
  • Delete: O(n)

Use Cases:

  1. Maintaining ordered data where you need indexed access
  2. Building collections incrementally where final size is unknown

Real-world example: I use List<T> for collecting validation errors in a multi-tenant application. Each tenant can have different validation rules, and I need to maintain the order of errors to display them consistently.

public class ValidationResult
{
    public List<string> Errors { get; } = [];
    public bool IsValid => Errors.Count == 0;
    
    public void AddError(string message)
    {
        Errors.Add(message);
    }
}

When not to use: Avoid List<T> when you’re frequently searching for items or inserting at the beginning. The O(n) search time becomes painful with large datasets.

See here the difference between ArrayList and List<T>.

2. Dictionary<TKey, TValue> - The Lookup Champion

Dictionary<TKey, TValue> is perfect for key-value pairs when you need quick lookups. It works with a hash table under the hood, giving you super-fast operations most of the time.

// Configuration cache example
Dictionary<string, string> config = new()
{
    ["DatabaseConnection"] = "Server=localhost;Database=MyApp;",
    ["ApiKey"] = "sk-1234567890abcdef",
    ["MaxRetries"] = "3"
};

// Fast lookup - O(1) average case
string dbConnection = config["DatabaseConnection"];

// Safe lookup with TryGetValue
if (config.TryGetValue("Timeout", out string? timeout))
{
    Console.WriteLine($"Timeout: {timeout}");
}

Performance Characteristics:

  • Lookup: O(1) average, O(n) worst case
  • Insert: O(1) average
  • Delete: O(1) average
  • Memory: Higher overhead due to hash table structure

Use Cases:

  1. Caching frequently accessed data with unique keys
  2. Mapping relationships between entities

Real-world example: In a multi-tenant SaaS app, I use Dictionary<int, TenantSettings> to cache tenant configurations. The tenant ID works as the key, and lookups happen almost instantly.

public class TenantCache
{
    private readonly Dictionary<int, TenantSettings> _cache = new();
    
    public TenantSettings GetSettings(int tenantId)
    {
        if (_cache.TryGetValue(tenantId, out TenantSettings? settings))
        {
            return settings;
        }
        
        // Load from database and cache
        settings = LoadFromDatabase(tenantId);
        _cache[tenantId] = settings;
        return settings;
    }
}

Common pitfall: Don’t use Dictionary<TKey, TValue> in multithreaded scenarios without proper synchronization. Use ConcurrentDictionary<TKey, TValue> instead.

3. HashSet - The Uniqueness Enforcer

HashSet<T> works like a dictionary that only stores keys - great when you need to make sure items are unique or check if something exists in your collection quickly.

// Tracking processed items
HashSet<string> processedFiles = ["file1.txt", "file2.txt"];

// Fast membership test - O(1)
if (processedFiles.Contains("file3.txt"))
{
    Console.WriteLine("Already processed");
}
else
{
    processedFiles.Add("file3.txt");
    ProcessFile("file3.txt");
}

// Set operations
HashSet<string> newFiles = ["file3.txt", "file4.txt"];
HashSet<string> unprocessed = new(newFiles);
unprocessed.ExceptWith(processedFiles); // Remove already processed

Performance Characteristics:

  • Membership test: O(1) average
  • Insert: O(1) average
  • Delete: O(1) average
  • No ordering guarantees

Use Cases:

  1. Eliminating duplicates from collections
  2. Fast membership testing in large datasets

Real-world example: When processing user permissions, I use HashSet<string> to store permission codes. Checking if a user has a specific permission becomes a simple contains operation.

public class UserPermissions
{
    private readonly HashSet<string> _permissions = new();
    
    public void AddPermission(string permission)
    {
        _permissions.Add(permission);
    }
    
    public bool HasPermission(string permission)
    {
        return _permissions.Contains(permission);
    }
    
    public bool HasAnyPermission(params string[] permissions)
    {
        return permissions.Any(_permissions.Contains);
    }
}

4. Queue - First In, First Out Processing

Queue<T> works on the FIFO (First In, First Out) principle - just like a line at the grocery store. It’s ideal for handling items in the exact order they show up.

// Background job queue
Queue<string> jobQueue = new();

// Add jobs
jobQueue.Enqueue("SendEmail");
jobQueue.Enqueue("ProcessPayment");
jobQueue.Enqueue("GenerateReport");

// Process jobs in order
while (jobQueue.Count > 0)
{
    string job = jobQueue.Dequeue();
    Console.WriteLine($"Processing: {job}");
    // Process job...
}

Performance Characteristics:

  • Enqueue: O(1)
  • Dequeue: O(1)
  • Peek: O(1)
  • Maintains insertion order

Use Cases:

  1. Background job processing systems
  2. Breadth-first search algorithms

Real-world example: I use Queue<T> for implementing a simple message processing system where messages must be handled in arrival order.

public class MessageProcessor
{
    private readonly Queue<Message> _messageQueue = new();
    
    public void EnqueueMessage(Message message)
    {
        _messageQueue.Enqueue(message);
    }
    
    public async Task ProcessMessagesAsync()
    {
        while (_messageQueue.Count > 0)
        {
            Message message = _messageQueue.Dequeue();
            await ProcessMessageAsync(message);
        }
    }
}

5. Stack - Last In, First Out Processing

Stack<T> follows LIFO (Last In, First Out) - just like a stack of plates where you grab from the top and add new ones on top.

// Undo functionality
Stack<string> undoStack = new();

// Perform operations
undoStack.Push("Created file");
undoStack.Push("Modified content");
undoStack.Push("Added formatting");

// Undo operations
while (undoStack.Count > 0)
{
    string lastAction = undoStack.Pop();
    Console.WriteLine($"Undoing: {lastAction}");
}

Performance Characteristics:

  • Push: O(1)
  • Pop: O(1)
  • Peek: O(1)
  • Reverse insertion order

Use Cases:

  1. Undo/redo functionality in applications
  2. Depth-first search algorithms
  3. Expression evaluation and parsing

Real-world example: When implementing a calculator that supports parentheses, I use Stack<char> to track opening brackets and ensure proper nesting.

public class ExpressionValidator
{
    public bool IsValidExpression(string expression)
    {
        Stack<char> brackets = new();
        
        foreach (char c in expression)
        {
            if (c == '(' || c == '[' || c == '{')
            {
                brackets.Push(c);
            }
            else if (c == ')' || c == ']' || c == '}')
            {
                if (brackets.Count == 0) return false;
                
                char opening = brackets.Pop();
                if (!IsMatchingPair(opening, c)) return false;
            }
        }
        
        return brackets.Count == 0;
    }
}

6. LinkedList - The Memory Efficient List

LinkedList<T> keeps items in nodes that connect to each other, which makes adding or removing items from anywhere in the list quick and easy.

// Music playlist where order matters
LinkedList<string> playlist = new();

// Add songs
LinkedListNode<string> firstSong = playlist.AddFirst("Song A");
LinkedListNode<string> lastSong = playlist.AddLast("Song C");

// Insert in middle
playlist.AddAfter(firstSong, "Song B");

// Navigate through playlist
LinkedListNode<string>? current = playlist.First;
while (current != null)
{
    Console.WriteLine($"Playing: {current.Value}");
    current = current.Next;
}

Performance Characteristics:

  • Insert/delete at known position: O(1)
  • Search: O(n)
  • No indexed access
  • Higher memory overhead per element

Use Cases:

  1. Frequent insertions/deletions in the middle of large collections
  2. LRU (Least Recently Used) cache implementations

Real-world example: I use LinkedList<T> for implementing an LRU cache where recently accessed items move to the front, and old items are removed from the back.

public class LRUCache<TKey, TValue>
{
    private readonly int _capacity;
    private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new();
    private readonly LinkedList<(TKey Key, TValue Value)> _items = new();
    
    public LRUCache(int capacity)
    {
        _capacity = capacity;
    }
    
    public TValue? Get(TKey key)
    {
        if (_map.TryGetValue(key, out LinkedListNode<(TKey Key, TValue Value)>? node))
        {
            // Move to front (most recently used)
            _items.Remove(node);
            _items.AddFirst(node);
            return node.Value.Value;
        }
        return default;
    }
    
    public void Put(TKey key, TValue value)
    {
        if (_map.ContainsKey(key))
        {
            // Update existing
            var node = _map[key];
            node.Value = (key, value);
            _items.Remove(node);
            _items.AddFirst(node);
        }
        else
        {
            // Add new
            if (_items.Count >= _capacity)
            {
                // Remove least recently used
                var last = _items.Last;
                _items.RemoveLast();
                _map.Remove(last!.Value.Key);
            }
            
            var newNode = _items.AddFirst((key, value));
            _map[key] = newNode;
        }
    }
}

When not to use: Avoid LinkedList<T> when you need indexed access or when your insertions/deletions are mostly at the end. List<T> is more efficient for those scenarios.

7. SortedList<TKey, TValue> - The Ordered Dictionary

SortedList<TKey, TValue> keeps key-value pairs sorted by key and uses less memory than SortedDictionary<TKey, TValue>.

// Price tiers sorted by minimum quantity
SortedList<int, decimal> priceTiers = new()
{
    [1] = 10.00m,      // 1-9 items
    [10] = 9.50m,      // 10-49 items
    [50] = 9.00m,      // 50-99 items
    [100] = 8.50m      // 100+ items
};

// Find applicable price tier
decimal GetPrice(int quantity)
{
    // Keys are sorted, so we can find the highest applicable tier
    var applicableTier = priceTiers.Keys
        .Where(minQty => quantity >= minQty)
        .LastOrDefault();
    
    return priceTiers[applicableTier];
}

Performance Characteristics:

  • Lookup: O(log n)
  • Insert: O(n) due to array shifting
  • Delete: O(n) due to array shifting
  • Memory efficient
  • Maintains sorted order

Use Cases:

  1. Small to medium sorted collections where memory is a concern
  2. Range queries on sorted data

When not to use: Avoid SortedList<TKey, TValue> for frequent insertions/deletions in large collections. The O(n) insertion cost becomes prohibitive.

8. SortedDictionary<TKey, TValue> - The Balanced Tree

SortedDictionary<TKey, TValue> keeps things in order using a balanced binary tree, which makes it good when you need to add or remove items often.

// Event timeline sorted by timestamp
SortedDictionary<DateTime, string> timeline = new();

// Add events (automatically sorted)
timeline[DateTime.Now.AddMinutes(-30)] = "User logged in";
timeline[DateTime.Now.AddMinutes(-15)] = "User viewed product";
timeline[DateTime.Now] = "User made purchase";

// Process events in chronological order
foreach (var (timestamp, eventDescription) in timeline)
{
    Console.WriteLine($"{timestamp:HH:mm}: {eventDescription}");
}

Performance Characteristics:

  • Lookup: O(log n)
  • Insert: O(log n)
  • Delete: O(log n)
  • Maintains sorted order
  • Higher memory overhead than SortedList

Use Cases:

  1. Large sorted collections with frequent modifications
  2. Time-series data processing

Real-world example: I use SortedDictionary<DateTime, List<LogEntry>> for log analysis where entries need to be processed in chronological order.

public class LogAnalyzer
{
    private readonly SortedDictionary<DateTime, List<LogEntry>> _logsByHour = new();
    
    public void AddLogEntry(LogEntry entry)
    {
        DateTime hourKey = entry.Timestamp.Date.AddHours(entry.Timestamp.Hour);
        
        if (!_logsByHour.ContainsKey(hourKey))
        {
            _logsByHour[hourKey] = [];
        }
        
        _logsByHour[hourKey].Add(entry);
    }
    
    public IEnumerable<(DateTime Hour, int Count)> GetHourlyStats()
    {
        return _logsByHour.Select(kvp => (kvp.Key, kvp.Value.Count));
    }
}

See here the difference between Lookup, SortedList<TKey, TValue> and SortedDictionary<TKey, TValue>.

9. ObservableCollection - The UI-Friendly Collection

ObservableCollection<T> sends out notifications when items change, which makes it great for UI scenarios where your interface needs to update automatically.

// Data binding in WPF/MAUI applications
ObservableCollection<TodoItem> todoItems = new();

// Subscribe to changes
todoItems.CollectionChanged += (sender, e) =>
{
    Console.WriteLine($"Collection changed: {e.Action}");
    // UI automatically updates
};

// Add items (triggers UI update)
todoItems.Add(new TodoItem { Title = "Buy groceries", IsCompleted = false });
todoItems.Add(new TodoItem { Title = "Walk the dog", IsCompleted = true });

// Remove items (triggers UI update)
todoItems.RemoveAt(0);

Performance Characteristics:

  • Same as List<T> for core operations
  • Additional overhead for change notifications
  • Not thread-safe

Use Cases:

  1. Data binding in WPF, WinUI, or MAUI applications
  2. Real-time updates in UI components

Real-world example: In a task management application, I use ObservableCollection<T> to bind task lists to the UI. When tasks are added or completed, the interface updates automatically.

public class TaskListViewModel
{
    public ObservableCollection<TaskItem> Tasks { get; } = new();
    
    public void AddTask(string title)
    {
        Tasks.Add(new TaskItem 
        { 
            Id = Guid.NewGuid(),
            Title = title, 
            CreatedDate = DateTime.Now 
        });
        // UI updates automatically
    }
    
    public void CompleteTask(Guid taskId)
    {
        var task = Tasks.FirstOrDefault(t => t.Id == taskId);
        if (task != null)
        {
            task.IsCompleted = true;
            // UI updates automatically via property change notification
        }
    }
}

10. ConcurrentDictionary<TKey, TValue> - The Thread-Safe Dictionary

ConcurrentDictionary<TKey, TValue> lets multiple threads safely work with the same dictionary without you having to add extra locks or synchronization code.

// Thread-safe cache for multi-threaded applications
ConcurrentDictionary<string, UserSession> activeSessions = new();

// Thread-safe operations
public class SessionManager
{
    private readonly ConcurrentDictionary<string, UserSession> _sessions = new();
    
    public UserSession GetOrCreateSession(string sessionId)
    {
        return _sessions.GetOrAdd(sessionId, id => new UserSession
        {
            Id = id,
            CreatedAt = DateTime.UtcNow,
            LastActivity = DateTime.UtcNow
        });
    }
    
    public bool UpdateLastActivity(string sessionId)
    {
        return _sessions.TryUpdate(sessionId, 
            session => session with { LastActivity = DateTime.UtcNow },
            _sessions.TryGetValue(sessionId, out var existing) ? existing : null);
    }
    
    public void CleanupExpiredSessions()
    {
        var cutoff = DateTime.UtcNow.AddHours(-24);
        var expiredSessions = _sessions
            .Where(kvp => kvp.Value.LastActivity < cutoff)
            .Select(kvp => kvp.Key);
        
        foreach (string sessionId in expiredSessions)
        {
            _sessions.TryRemove(sessionId, out _);
        }
    }
}

Performance Characteristics:

  • Lookup: O(1) average with thread-safety overhead
  • Insert: O(1) average with thread-safety overhead
  • Delete: O(1) average with thread-safety overhead
  • Thread-safe without external locking
  • Higher memory overhead than Dictionary

Use Cases:

  1. Multi-threaded applications requiring shared state
  2. Caching scenarios with concurrent access

When not to use: Don’t use ConcurrentDictionary<TKey, TValue> in single-threaded scenarios where you don’t need thread safety. The overhead isn’t worth it.

Performance Comparison Table

CollectionLookupInsertDeleteThread SafeOrderingMemory Overhead
ListO(n)O(1)*O(n)NoInsertionLow
Dictionary<TKey, TValue>O(1)*O(1)*O(1)*NoNoneMedium
HashSetO(1)*O(1)*O(1)*NoNoneMedium
QueueN/AO(1)O(1)NoFIFOLow
StackN/AO(1)O(1)NoLIFOLow
LinkedListO(n)O(1)**O(1)**NoInsertionHigh
SortedList<TKey, TValue>O(log n)O(n)O(n)NoSortedLow
SortedDictionary<TKey, TValue>O(log n)O(log n)O(log n)NoSortedHigh
ObservableCollectionO(n)O(1)*O(n)NoInsertionMedium
ConcurrentDictionary<TKey, TValue>O(1)*O(1)*O(1)*YesNoneHigh

*Amortized time complexity **When you have a reference to the node

Collection Relationships Diagram

graph TB
    A[Choose Collection] --> B{Need Key-Value Pairs?}
    B -->|Yes| C{Thread Safe?}
    B -->|No| D{Need Unique Items?}
    
    C -->|Yes| E[ConcurrentDictionary]
    C -->|No| F{Need Sorting?}
    
    F -->|Yes| G{Frequent Modifications?}
    F -->|No| H[Dictionary]
    
    G -->|Yes| I[SortedDictionary]
    G -->|No| J[SortedList]
    
    D -->|Yes| K[HashSet]
    D -->|No| L{Processing Order?}
    
    L -->|FIFO| M[Queue]
    L -->|LIFO| N[Stack]
    L -->|Index Access| O{UI Binding?}
    L -->|Insert/Delete Middle| P[LinkedList]
    
    O -->|Yes| Q[ObservableCollection]
    O -->|No| R[List]
    

Mental Models for Collections

Think of collections like everyday objects:

  • List: A numbered list on paper, you can access any item by its position
  • Dictionary<TKey, TValue>: A phone book, look up by name to get the number
  • HashSet: A guest list at an exclusive event, either you’re on it or you’re not
  • Queue: A line at the coffee shop, first come, first served
  • Stack: A stack of papers on your desk, you can only work with the top one
  • LinkedList: A chain of sticky notes, easy to insert new ones anywhere

Choosing the Right Collection: A Decision Framework

Ask yourself these questions in order:

  1. Do I need thread safety?ConcurrentDictionary<TKey, TValue> or add synchronization
  2. Do I need key-value pairs?Dictionary<TKey, TValue> or SortedDictionary<TKey, TValue>
  3. Do I need to maintain sorting?SortedList<TKey, TValue> or SortedDictionary<TKey, TValue>
  4. Do I need unique items only?HashSet<T>
  5. Do I need FIFO processing?Queue<T>
  6. Do I need LIFO processing?Stack<T>
  7. Do I need UI data binding?ObservableCollection<T>
  8. Do I frequently insert/delete in the middle?LinkedList<T>
  9. OtherwiseList<T>

Common Pitfalls to Avoid

Resizing inside loops: Don’t add items to a List<T> inside a loop that iterates over the same list. The collection may resize, causing performance issues.

// Bad
for (int i = 0; i < items.Count; i++)
{
    if (SomeCondition(items[i]))
    {
        items.Add(CreateNewItem()); // Causes resizing
    }
}

// Good
var itemsToAdd = new List<Item>();
for (int i = 0; i < items.Count; i++)
{
    if (SomeCondition(items[i]))
    {
        itemsToAdd.Add(CreateNewItem());
    }
}
items.AddRange(itemsToAdd);

Using List for frequent searches: If you’re calling Contains() or IndexOf() frequently, switch to HashSet<T> or Dictionary<TKey, TValue>.

Ignoring thread safety: Don’t use regular collections in multi-threaded scenarios without proper synchronization.

Key Takeaways

The right collection choice depends on your specific use case:

  • Use List<T> for ordered data with indexed access
  • Use Dictionary<TKey, TValue> for fast key-based lookups
  • Use HashSet<T> for uniqueness and fast membership tests
  • Use Queue<T> and Stack<T> for specific processing orders
  • Use LinkedList<T> for frequent middle insertions/deletions
  • Use sorted collections when you need maintained order
  • Use ObservableCollection<T> for UI data binding
  • Use ConcurrentDictionary<TKey, TValue> for thread-safe scenarios

Remember: premature optimization might be a bad idea, but picking an obviously wrong collection is just sloppy work. Start with something simple that does the job, then make it faster if you actually need to (and have measurements to prove it).

Next time you start typing List<T>, stop for a second and think: “Is this actually the right tool here?” Trust me, your future self (and your app’s performance) will be grateful.

Related Posts