AlgoMaster Logo

Dictionary<TKey, TValue>

Last Updated: May 17, 2026

14 min read

Dictionary<TKey, TValue> is the hash-based key-value store in the .NET base class library. It maps keys to values with average O(1) lookup, insert, and remove, which makes it the right choice whenever you need to look something up "by id" instead of "by position." This lesson also owns the hashing basics that other collections in this section (HashSet, ConcurrentDictionary, ImmutableDictionary) will reuse.

What a Dictionary Is For

A List<T> is good when you need an ordered sequence and you mostly walk it from front to back. The moment you need to answer "what's the product with id 42?" on a list of ten thousand products, you're stuck scanning until you find it. That scan is O(n), which means doubling the catalog doubles the lookup time. A Dictionary<int, Product> answers the same question in roughly the same time whether the catalog has ten products or ten million.

The trade is that the dictionary doesn't preserve a meaningful order, doesn't allow duplicate keys, and uses extra memory for the bucket table. When those costs don't matter and lookup speed does, Dictionary is the default tool.

Here's the shape of a dictionary keyed by product id:

The dictionary holds three entries. The lookup catalog[101] returns the Product whose key is 101 without scanning the other entries. The number 101 is hashed, the hash points to a bucket, and the runtime reads the product from that bucket.

The type parameters are the key type and the value type. The key is what you look up by. The value is what gets returned. They can be any types you like, but the key type's choice has consequences (equality, hashing) that the rest of this lesson works through.

Constructing a Dictionary

There are several ways to build a dictionary, and each has a real use case.

The simplest is the no-argument constructor:

This creates an empty dictionary with default initial capacity. The bucket array starts small and resizes as you add entries.

If you know roughly how many entries you'll add, pass a capacity hint:

The constructor allocates enough buckets up front to hold about that many entries without resizing. This matters when you're filling a dictionary in a loop with a known size, because each resize copies every entry into a larger bucket array, and copying ten thousand entries one resize after another is wasted work.

You can also pass an IEqualityComparer<TKey> when you want the dictionary to compare keys differently from the default. The most common reason is case-insensitive string keys, covered later in this chapter.

A dictionary can be built from any IEnumerable<KeyValuePair<TKey, TValue>>. That's how you copy one dictionary into another, or convert a sequence into a dictionary using LINQ's ToDictionary:

ToDictionary(p => p.Id) walks the list, picks the Id of each product as the key, and uses the whole product as the value. That's the idiomatic way to turn a list you loaded from a database or a file into a fast lookup table.

The collection initializer gives you a compact literal form when you know the entries at compile time:

Each { key, value } pair calls Add(key, value) under the hood. Because it's Add and not the indexer, two entries with the same key in a collection initializer will throw at runtime.

There's also a newer syntax, the dictionary initializer, that uses indexer notation:

The two initializers look similar but compile to different calls. { key, value } calls Add. [key] = value calls the indexer setter. The practical difference is duplicate handling: Add throws on duplicates, the indexer overwrites. We'll see why that matters below.

Add vs Indexer Assignment

This is the most common stumbling block with Dictionary, so it deserves its own section.

Add(key, value) throws ArgumentException if the key is already present:

The original value 50 is unchanged because the failed Add doesn't touch the existing entry. This is the right behavior when you don't expect duplicates and want to be told if one slips in. A catalog import that's supposed to have unique product ids should use Add, so a duplicate row in the source file becomes a loud failure.

The indexer assignment is the opposite. It silently overwrites:

The first assignment creates the entry. The second replaces the value with 75. No exception, no warning, no record that the old value ever existed. This is the right behavior when "set the current value, whatever it was" is what you actually mean, like updating a stock count after a sale.

The rule is short. Use Add when "this key is new" is part of the contract. Use the indexer when "this key now maps to this value" is the contract. Don't mix the two and expect one to behave like the other.

TryAdd (added in .NET Core 2.0) is the third option. It adds the entry if the key is new and returns true, or does nothing and returns false if the key already exists:

TryAdd is the right shape when you want "insert if absent" without writing the if (!dict.ContainsKey(...)) dict.Add(...) dance, and without paying the cost of an exception when the duplicate case is normal.

OperationIf key absentIf key presentThrows on duplicate?
Add(k, v)insertsdoes nothing to dictionaryyes
dict[k] = vinsertsoverwritesno
TryAdd(k, v)inserts, returns trueleaves value, returns falseno

Reading Values: Indexer, TryGetValue, GetValueOrDefault

The indexer is the obvious way to read a value. It's also the one that throws when the key isn't there:

KeyNotFoundException is what you get when you ask the indexer for a key that isn't in the dictionary. The exception is fine when the key really should always be present and missing means a bug. It's not fine when missing is a normal case, because using exceptions for normal control flow is slow and noisy.

For "the key might or might not be there," use TryGetValue:

TryGetValue returns true and writes the value into the out parameter when the key is present, or returns false and leaves the parameter at its default when the key is absent. No exception either way. This is the idiomatic shape for "look up the value if it exists" in C#.

GetValueOrDefault is shorter when you don't need to distinguish "missing" from "present but default":

The one-argument overload uses the type's default (zero for numbers, null for reference types). The two-argument overload uses the value you pass. This is useful when the dictionary is conceptually "a count per id" and a missing key just means zero.

A subtle point: GetValueOrDefault can hide bugs if you actually do care about the difference between "key absent" and "key present with the default value." If a stock count of zero means "out of stock" but a missing key means "we don't carry this product at all," GetValueOrDefault collapses both into the same answer. Use TryGetValue when that distinction matters.

Checking for Keys and Values

ContainsKey answers "is this key in the dictionary?" in O(1) time on average:

ContainsKey is the right tool when you don't actually need the value, just the answer to "do we have an entry for this key?" If you need both, prefer TryGetValue, which does the lookup once instead of twice.

ContainsValue answers "does any entry have this value?" in O(n) time:

The O(n) cost is the important part. The bucket table indexes by key, not by value, so finding a value requires walking every entry and comparing. If you find yourself calling ContainsValue frequently on a large dictionary, you probably want a second dictionary keyed by the value, or a different data shape entirely.

Removing and Clearing

Remove(key) removes the entry with the given key and returns true if it was there, false if it wasn't:

There's also an overload that returns the removed value through an out parameter, useful when you want to act on the value as you remove it:

Clear() empties the dictionary in one call:

Clear keeps the underlying bucket array allocated. If you're about to refill the dictionary with similar size, this is a small win because there's no resize. If you're done with the dictionary entirely, let it go out of scope and the garbage collector reclaims the memory.

Keys, Values, and Iteration

A dictionary exposes two read-only views: Keys and Values. They're not copies. They're live views over the underlying data, so they reflect changes to the dictionary in real time:

The order in which keys come out is not guaranteed to be insertion order, and it's not sorted either. It depends on the bucket layout, which depends on the hash codes. If you need a specific order, sort the keys explicitly with Keys.OrderBy(...) or use a different collection like SortedDictionary.

To iterate keys and values together, foreach the dictionary directly. Each element is a KeyValuePair<TKey, TValue>:

Since C# 7, KeyValuePair supports deconstruction, which is the form most modern C# code uses:

var (id, name) unpacks the pair into two named variables. It reads better than pair.Key and pair.Value, and the IL it compiles to is the same.

One rule that bites people: you can't modify a dictionary while iterating it. Adding, removing, or replacing entries inside a foreach invalidates the enumerator, and the next iteration step throws InvalidOperationException with the message "Collection was modified."

The fix is to collect the keys you want to remove first, then remove them after the loop ends:

The first line builds a temporary list of keys to remove. The loop then removes them outside the iteration of the dictionary, so the enumerator is never invalidated.

How Dictionary Works Under the Hood

The dictionary stores entries in an array of buckets. To find an entry, it hashes the key, mods the hash by the bucket count, and looks at that bucket. If two keys land in the same bucket (a collision), they're chained together in a linked list of entries, and the dictionary walks the chain comparing keys until it finds the right one or reaches the end.

The diagram shows a lookup of the key 102. The hash code of 102 is computed, the hash gets reduced to a bucket index (7), and bucket 7 holds a chain with three entries that all hashed to the same index. The dictionary walks the chain (entry 102, entry 218, entry 999) comparing keys with Equals until it finds 102 and returns its value.

When the chains stay short, every operation is O(1) on average: one hash, one bucket lookup, one or two equality checks. When the chains grow long (because too many keys collide, or because the bucket array hasn't been resized), operations degrade toward O(n) in the worst case. The dictionary fights this by resizing the bucket array when the load gets high, which redistributes the entries into more buckets and shortens the chains.

Here's the flow for an Add operation:

This is what Add does. The indexer setter takes the same path but replaces the value at the "key already present" branch instead of throwing. TryAdd takes the same path but returns false at that branch instead of throwing.

The whole structure leans on two operations on the key: GetHashCode() and Equals(). If either is wrong, the dictionary stops working correctly. That's the contract this lesson covers next.

Hashing and the Equality Contract

Every type in .NET inherits GetHashCode() and Equals(object) from object. The default implementations work for any type, but they may not do what you want for keys.

The contract between these two methods is the rule everyone has to follow:

In plain words: equal objects must hash to the same code. The reverse doesn't have to hold (two unequal objects can hash to the same code, which is just a collision and is normal), but the forward direction is mandatory.

If you break this contract, the dictionary breaks in subtle ways. An object you just added will appear "missing" because the lookup hashes to a different bucket than the insert did. Two equal keys can end up as two separate entries because they hash differently.

Here's a broken example to make the point concrete:

a and b are equal because Equals was overridden to compare by Id. But GetHashCode wasn't overridden, so it uses the default reference-based hash, which is different for two different Customer instances. The dictionary stores a in one bucket. When asked about b, it hashes b to a different bucket and doesn't find anything there, so it reports the key is missing.

The fix is to override GetHashCode to be consistent with Equals:

Now a and b are equal and hash to the same code, so the dictionary treats them as the same key. Looking up by b finds the entry that was inserted with a.

A record type does both overrides for you automatically based on its declared properties, which is one of the reasons records have become popular as dictionary keys:

Records generate value-based Equals and GetHashCode that compare the declared properties. As long as the properties you list are the right ones for "what makes two keys the same," this just works.

For value types (int, string, Guid, DateTime, custom structs), the BCL already provides correct implementations. Strings hash by their character content. Integers hash by their bit pattern. Guids hash by their underlying bytes. You can use these as keys without doing anything yourself.

Reference types you defined yourself (plain class) use reference equality by default, which is the source of the bug we just looked at. If a class is going to be used as a dictionary key and you want value semantics, override both methods. Or make it a record.

Custom Equality with IEqualityComparer

Sometimes you don't control the key type and can't change its Equals/GetHashCode. Or you want the same type to behave one way in one dictionary and a different way in another. For example, you want string keys to be compared case-insensitively in one place and case-sensitively in another.

The dictionary constructor accepts an IEqualityComparer<TKey> for exactly this. The comparer provides the Equals and GetHashCode the dictionary uses, instead of the ones on the key type.

The BCL ships a set of ready-made string comparers in StringComparer. The most common is StringComparer.OrdinalIgnoreCase, which compares strings byte-by-byte but ignoring ASCII case differences:

The dictionary was constructed with StringComparer.OrdinalIgnoreCase, so all key comparisons ignore case. Looking up "MUG" finds the entry that was inserted as "mug", and looking up "notebook" finds the one inserted as "Notebook".

The default for Dictionary<string, T> is case-sensitive comparison. Without specifying a comparer, dict["MUG"] would throw KeyNotFoundException even when dict["mug"] exists.

StringComparer has several variants worth knowing:

ComparerBehavior
StringComparer.OrdinalCase-sensitive, byte-by-byte. The fastest.
StringComparer.OrdinalIgnoreCaseCase-insensitive, byte-by-byte. Fast.
StringComparer.InvariantCultureCulture-aware but uses the invariant culture.
StringComparer.InvariantCultureIgnoreCaseCulture-aware, case-insensitive, invariant.
StringComparer.CurrentCultureUses the running thread's current culture. Avoid for stable keys.
StringComparer.CurrentCultureIgnoreCaseSame, case-insensitive. Avoid.

For most dictionary key scenarios where the strings are identifiers, codes, or values that aren't shown to users, the ordinal variants are the right choice. They're the fastest and they don't change behavior based on what language the user has installed.

For other key types, you can write your own IEqualityComparer<T>. The surface here is simple: you pass a comparer to the constructor and the dictionary defers all key comparisons to it.

Capacity and EnsureCapacity

The dictionary's bucket array grows as you add entries. Each growth roughly doubles the array and rehashes every entry into the new buckets, which is O(n) work at that moment.

If you know up front how many entries you'll add, two options avoid the resizes:

EnsureCapacity (.NET Core 2.1+) sets the bucket array to a size that can hold at least the given number of entries without resizing. It returns the actual capacity, which is typically the next prime number at or above the request. The method does nothing if the existing capacity is already enough.

Output (typical):

The exact number depends on the runtime's internal sizing logic. The contract is "at least n," which is what callers actually need.

TrimExcess() does the opposite: it shrinks the bucket array to fit the current count, which can save memory after you've removed a lot of entries. It's rarely worth calling because dictionaries don't usually live long enough for that to matter, but it exists for the case where it does.

Thread Safety

Dictionary<TKey, TValue> is not thread-safe. Reading and writing it from multiple threads at the same time, or even writing from one thread while another reads, can corrupt the bucket array and produce undefined behavior, including infinite loops inside the dictionary's own code.

Reading from multiple threads at the same time is safe, but only if no thread is writing. As soon as a single thread writes, all readers and writers need to coordinate, either through a lock or by switching to a thread-safe collection.

For multi-threaded scenarios, use ConcurrentDictionary<TKey, TValue> from System.Collections.Concurrent. It provides atomic operations like AddOrUpdate, GetOrAdd, and TryUpdate, and its design is built around the assumption that multiple threads will hit it at once.

The short rule: one thread, Dictionary. Multiple threads with shared mutation, ConcurrentDictionary. Multiple threads reading a snapshot that never changes after construction, Dictionary is fine but consider ImmutableDictionary for clarity.

Summary

  • Dictionary<TKey, TValue> is a hash-based key-value store with O(1) average lookup, insert, and remove. Worst case is O(n) when keys collide heavily, but the runtime resizes to keep chains short.
  • Add throws on duplicate keys, the indexer setter overwrites silently, and TryAdd inserts only if absent and returns a bool. Pick the one whose behavior on duplicates matches your contract.
  • TryGetValue is the idiomatic read when the key might be missing. The indexer throws KeyNotFoundException on a missing key. GetValueOrDefault is shorter when you don't care about distinguishing "missing" from "present with default value."
  • ContainsKey is O(1), ContainsValue is O(n) because the dictionary indexes only by key. If you need fast lookup by value, build a second dictionary or use a different shape.
  • Keys must obey the contract that a.Equals(b) implies a.GetHashCode() == b.GetHashCode(). Reference types you write need to override both methods (or be records) before they work as keys. StringComparer.OrdinalIgnoreCase and other comparers let you change the equality behavior at the dictionary level instead of on the key type.
  • The internal layout is an array of buckets with chained entries. Hashing the key picks a bucket, walking the chain finds the entry. EnsureCapacity and the capacity constructor avoid resizes when you know the size up front.
  • Dictionary is not thread-safe. Use ConcurrentDictionary for concurrent mutation, or ImmutableDictionary for safely-shared read-only snapshots.