C# Dictionary Duplicate Key Handling
Dictionary.Add throws when a key already exists, while the indexer overwrites. See how to choose among Add, TryAdd, indexer updates, custom comparers, and ConcurrentDictionary.
Handling duplicate keys in a C# Dictionary is a frequent source of bugs because the default behavior throws an exception rather than silently overwriting or ignoring the new value. When you call Add on a Dictionary<TKey, TValue> with a key that already exists, the runtime throws an ArgumentException. This article covers the main strategies for dealing with duplicate keys: overwriting, skipping, throwing, and custom comparers, along with thread-safe alternatives and performance tradeoffs.
What Happens When You Add a Duplicate Key
The Dictionary<TKey, TValue> class enforces unique keys by design. The Add method checks for an existing key and throws ArgumentException if it finds one. The indexer, on the other hand, does not throw; it replaces the existing value. This difference is the root of most duplicate-key confusion.
var dict = new Dictionary<string, int>(); dict.Add("key", 1); try { dict.Add("key", 2); // Throws ArgumentException } catch (ArgumentException ex) { Console.WriteLine($"Duplicate key: {ex.Message}"); } dict["key"] = 3; // Overwrites, no exception Console.WriteLine(dict["key"]); // Output: 3
The Add method is strict: it signals a programming error if you attempt to insert a duplicate. The indexer is permissive: it always sets the value, overwriting whatever was there. Choosing between them depends on whether a duplicate key indicates a logic error or an expected update.
Using the Indexer to Overwrite Values
When you want to insert or update a value without caring whether the key already exists, the indexer is the simplest option. It performs an implicit Add if the key is absent, or an update if it is present.
var settings = new Dictionary<string, string>(); settings["theme"] = "dark"; settings["theme"] = "light"; // Overwrites "dark"
This pattern is common in configuration loading, caching, and any scenario where the latest value should win. It is also faster than a ContainsKey check followed by an Add, because the indexer performs the lookup only once internally. However, it does not tell you whether the key already existed, so you cannot react to the difference between insert and update.
Using TryAdd to Insert Only If the Key Is Absent
Starting with .NET Core 2.0, Dictionary<TKey, TValue> provides a TryAdd method that returns true if the key was added and false if it already exists. This avoids the exception overhead and the need for a separate ContainsKey check.
var dict = new Dictionary<string, int>(); bool added = dict.TryAdd("key", 10); Console.WriteLine(added); // True added = dict.TryAdd("key", 20); Console.WriteLine(added); // False Console.WriteLine(dict["key"]); // Still 10
TryAdd combines the existence check and insertion in one call, so you avoid a separate ContainsKey lookup. It does not make the dictionary thread-safe; in multithreaded scenarios, use ConcurrentDictionary instead.
Checking for Keys Before Adding
Before TryAdd existed, the typical pattern was to call ContainsKey and then decide whether to add or update. This still works and gives you explicit control over the logic.
var dict = new Dictionary<string, int>(); if (dict.ContainsKey("key")) { dict["key"] = dict["key"] + 1; } else { dict["key"] = 1; }
This pattern is clear and easy to read, but it is more verbose than TryAdd. When the key exists, the code calls ContainsKey, then reads and writes through the indexer, so it performs additional lookups. In performance-sensitive code, TryAdd or the indexer alone is more efficient. Also, in multithreaded code, ContainsKey followed by Add or an indexer assignment is not atomic and can cause a race condition. The ConcurrentDictionary provides atomic methods for these operations.
Handling Duplicates with Custom Equality Comparers
Sometimes two keys are considered equal based on custom logic, not the default reference or value equality. For example, you might want case-insensitive string keys. You can pass a custom IEqualityComparer<TKey> to the Dictionary constructor to control how keys are compared.
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); dict.Add("KEY", 1); dict["key"] = 2; // Same key under this comparer, so this overwrites the previous value Console.WriteLine(dict.Count); // 1 Console.WriteLine(dict["key"]); // 2
The comparer determines when two keys are considered duplicates. Using Add with a key the comparer considers equal would still throw; the indexer performs the overwrite. This is useful for case-insensitive lookups, culture-aware comparisons, or custom object equality. However, be careful: changing the comparer changes the semantics of the dictionary. The same key string with different casing will be treated as the same key, which can be surprising if you expect them to be distinct.
Using Lookup for Multiple Values per Key
If your data model allows multiple values for the same key, a Dictionary is the wrong structure. The Lookup<TKey, TElement> class, available in LINQ, maps each key to a sequence of values. It is immutable and typically created via ToLookup.
var items = new[] { ("a", 1), ("b", 2), ("a", 3) }; var lookup = items.ToLookup(x => x.Item1, x => x.Item2); foreach (var value in lookup["a"]) { Console.WriteLine(value); // 1, 3 }
Lookup is ideal when a key naturally has multiple values, such as grouping records by category. It does not throw on duplicate keys because duplicates are expected. However, it is not a mutable collection; you cannot add or remove entries after creation. For a mutable multi-value dictionary, you might use Dictionary<TKey, List<TValue>> and manage the list manually.
Thread-Safe Duplicate Handling with ConcurrentDictionary
When multiple threads read and write the same dictionary, the regular Dictionary is not thread-safe. ConcurrentDictionary<TKey, TValue> provides atomic methods for duplicate-key handling. TryAdd works like the regular version, but AddOrUpdate and GetOrAdd give you more control.
var concurrent = new ConcurrentDictionary<string, int>(); concurrent.TryAdd("key", 1); concurrent.AddOrUpdate("key", 2, (k, old) => old + 1); Console.WriteLine(concurrent["key"]); // 2
AddOrUpdate takes a value to add if the key is absent, and a delegate to compute the new value if the key exists. GetOrAdd returns the existing value or adds a new one if the key is missing. These methods are atomic, so you do not need external locks for common update patterns. However, ConcurrentDictionary has higher memory overhead than Dictionary due to internal partitioning and locking, so use it only when thread safety is actually required.
Performance and Maintainability Considerations
The choice of duplicate-key handling affects both runtime performance and code maintainability. ContainsKey followed by indexer access performs more lookups than TryAdd or a single indexer assignment. In tight loops, this difference matters. Custom comparers can also add overhead if they are computationally expensive, so use them only when needed.
From a maintainability perspective, the indexer is concise but hides the distinction between insert and update. TryAdd makes the intent explicit and avoids throwing, which is useful when duplicates are expected. Add is the strictest and forces you to handle the exception, which is appropriate when a duplicate key indicates a bug.
For thread-safe code, ConcurrentDictionary is the only safe choice for shared mutable state. Its atomic methods reduce the chance of race conditions, but they come with a performance cost. In single-threaded scenarios, stick with Dictionary and pick the method that matches your data flow.
Ultimately, the right approach depends on whether a duplicate key is an error, an update, or a normal occurrence. By understanding the default behavior and the available alternatives, you can handle duplicate keys without surprising exceptions or subtle bugs.