Back to Blog
C#

C# Dictionary Duplicate Key Handling

c# dictionary duplicate key handling: Learn how to handle duplicate keys in C# Dictionary: overwrite, skip, throw, or use custom comparers and thread-safe alternatives.

C#DictionaryDuplicate KeysTryAddConcurrentDictionary
A C# Dictionary with a key that appears twice, showing a decision between throwing an exception, overwriting, or skipping.

C# dictionary duplicate key handling 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 for Safe Insertion

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 is atomic in the sense that it checks and adds in one operation, which is important in single-threaded code for correctness. In multithreaded scenarios, you should use ConcurrentDictionary instead, as the regular Dictionary is not thread-safe.

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 performs two lookups when the key exists (one in ContainsKey and one in the indexer). In performance-sensitive code, TryAdd or the indexer alone is more efficient. Also, in multithreaded code, ContainsKey followed by Add or Update 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.Add("key", 2); // This does NOT throw; both map to the same key Console.WriteLine(dict.Count); // 1 Console.WriteLine(dict["key"]); // 2

The comparer determines when two keys are considered duplicates. 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 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 an indexer update performs two lookups, while TryAdd or the indexer alone performs one. 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.

c# dictionary duplicate key handling: Practical Usage and Co | RYUSLOG DEV