C# Dictionary Usage: Key Operations and Performance
c# dictionary usage: Learn how to use C# Dictionary for fast key-value lookups, handle missing keys, optimize performance, and choose thread-safe alternatives.
When working with key-value pairs in C#, the Dictionary<TKey, TValue> class is the default choice for fast lookups. Understanding its behavior—from basic operations to performance tradeoffs—helps you use it correctly in real applications. This article covers the core patterns of c# dictionary usage, including how to handle missing keys, iterate efficiently, and decide when a concurrent collection is necessary.
Declaring and Initializing a Dictionary
A dictionary is declared with two type parameters: the key type and the value type. The simplest declaration creates an empty dictionary, but you can also populate it at initialization using a collection initializer.
var empty = new Dictionary<string, int>(); var populated = new Dictionary<string, int> { ["apple"] = 1, ["banana"] = 2, ["cherry"] = 3 };
The indexer syntax in the initializer assigns values directly. This is equivalent to calling Add for each entry, but it will overwrite an existing key instead of throwing an exception. If you want to guarantee that a key is not already present, use the Add method explicitly.
Adding, Retrieving, and Updating Entries
The Add method inserts a new key-value pair and throws an ArgumentException if the key already exists. The indexer, on the other hand, can be used for both insertion and update—it adds the key if it does not exist and overwrites the value if it does.
var inventory = new Dictionary<string, int>(); inventory.Add("widget", 5); inventory["widget"] = 10; // update inventory["gadget"] = 3; // insert
Retrieving a value with the indexer throws a KeyNotFoundException when the key is missing. This is often undesirable because it requires exception handling for a routine lookup. Instead, use TryGetValue to retrieve a value without throwing.
if (inventory.TryGetValue("widget", out int count)) { Console.WriteLine($"Widget count: {count}"); } else { Console.WriteLine("Widget not found."); }
TryGetValue is both safer and faster than a ContainsKey followed by an indexer access, because it performs only one lookup instead of two.
Checking for Keys Without Throwing Exceptions
If you only need to know whether a key exists, ContainsKey is the straightforward method. However, when you also need the value, TryGetValue is the better pattern because it combines existence check and retrieval in one operation.
if (inventory.ContainsKey("widget")) { // Still need to fetch the value separately int count = inventory["widget"]; }
This performs two hash lookups. In a loop or a hot path, that extra lookup can add measurable overhead. Prefer TryGetValue when you need the value.
For cases where a missing key should default to a specific value, you can use GetValueOrDefault with a fallback, but be aware that this method is available on Dictionary as an extension in .NET Core 2.0+ and .NET Standard 2.1. It does not mutate the dictionary.
int count = inventory.GetValueOrDefault("widget", 0);
Iterating Over a Dictionary
A dictionary maintains its entries in an internal order that is not guaranteed to match insertion order. Iterating with foreach yields KeyValuePair<TKey, TValue> items. You can also iterate over just the keys or just the values.
foreach (var kvp in inventory) { Console.WriteLine($"{kvp.Key}: {kvp.Value}"); } foreach (var key in inventory.Keys) { Console.WriteLine(key); } foreach (var value in inventory.Values) { Console.WriteLine(value); }
Modifying the dictionary during iteration (adding or removing entries) throws an InvalidOperationException because the underlying collection changes. If you need to remove entries while iterating, collect the keys first and then remove them after the loop.
var keysToRemove = inventory.Keys.Where(k => k.StartsWith("w")).ToList(); foreach (var key in keysToRemove) { inventory.Remove(key); }
Performance Characteristics and Capacity
The Dictionary is implemented as a hash table. Lookup, insertion, and removal have an average time complexity of O(1), but this depends on the quality of the hash function and the load factor. When the number of entries exceeds the current capacity, the dictionary resizes, which involves rehashing all existing entries. This is an O(n) operation that can cause a noticeable pause if the dictionary is large.
You can reduce resizing overhead by providing an initial capacity when you know the approximate number of entries.
var large = new Dictionary<string, int>(10000);
This pre-allocates enough buckets to hold 10,000 entries without resizing. The default capacity is small, so a dictionary that grows significantly will resize several times.
Hash collisions degrade performance because multiple keys map to the same bucket, forcing a linear scan. The default EqualityComparer<TKey>.Default uses the key's GetHashCode implementation. For strings, the runtime uses a randomized hash to reduce collision attacks, but for custom types you must provide a good GetHashCode implementation. A poor hash function can turn dictionary operations into O(n) scans.
Thread Safety and ConcurrentDictionary
Dictionary<TKey, TValue> is not thread-safe for concurrent reads and writes. If multiple threads access a dictionary without synchronization, you risk corruption or exceptions. The ConcurrentDictionary<TKey, TValue> class in System.Collections.Concurrent is designed for multi-threaded scenarios. It uses fine-grained locking and lock-free reads to allow safe concurrent access.
var concurrent = new ConcurrentDictionary<string, int>(); concurrent.TryAdd("key", 1); concurrent["key"] = 2; // atomic update ```n `ConcurrentDictionary` provides atomic methods like `TryAdd`, `TryUpdate`, and `GetOrAdd`, which are useful for implementing caches or counters. Its performance is slightly lower than `Dictionary` for single-threaded access because of the overhead of synchronization, but it is the correct choice when multiple threads need to modify the collection. If you only need read-only concurrent access, you can use `Dictionary` with a lock or use `ReadOnlyDictionary` after building the data, but for frequent writes, `ConcurrentDictionary` is simpler and safer. ## Custom Equality Comparers for Non-Standard Keys When using custom types as keys, the default equality comparer uses `object.Equals` and `object.GetHashCode`. If you do not override these methods, the dictionary will use reference equality, which is rarely what you want. You have two options: override `Equals` and `GetHashCode` on the key type, or pass a custom `IEqualityComparer<TKey>` to the dictionary constructor. Overriding the methods on the type is cleaner when the type has a natural identity. For example, a `Person` class with `Id` and `Name` might consider two instances equal if their `Id` matches. ```csharp public class Person { public int Id { get; set; } public string Name { get; set; } public override bool Equals(object obj) => obj is Person other && Id == other.Id; public override int GetHashCode() => Id.GetHashCode(); }
If you cannot modify the key type, or if you need different equality rules for different dictionaries, pass a custom comparer.
var caseInsensitive = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase);
StringComparer provides several predefined comparers for case-insensitive or culture-aware string keys. For custom logic, implement IEqualityComparer<T>.
public class PersonIdComparer : IEqualityComparer<Person> { public bool Equals(Person x, Person y) => x.Id == y.Id; public int GetHashCode(Person obj) => obj.Id.GetHashCode(); }
Then pass it to the constructor. This approach keeps the dictionary behavior separate from the type's own equality semantics, which is useful when the same type needs different identity rules in different contexts.