Updated on

Reading a key a Dictionary<TKey, TValue> does not hold throws a KeyNotFoundException. When a missing key is an ordinary case rather than a bug, GetValueOrDefault() is the method we want: it returns default(TValue) instead of throwing, and its second overload returns a fallback we choose.

The alternatives are TryGetValue(), which also tells us whether the key was there, and a ContainsKey() check before the indexer, which searches the dictionary twice to learn one thing.

To download the source code for this article, you can visit our GitHub repository.

When Do We Need to Return a Default Value From a Dictionary?

A Dictionary<TKey, TValue> throws a KeyNotFoundException when we read a key it does not hold. The indexer has no other option: it must hand back a TValue, and there is no TValue to hand back.

Most of the time that exception is not what we want. A settings lookup, a counter, a cache of computed results, a map from a code to a label: in all of them a missing key is an ordinary case rather than a failure, and the right answer is a fallback value.

.NET gives us four ways to ask for one, and they are not interchangeable. A ContainsKey() check before the indexer works, but searches the dictionary twice. TryGetValue() searches once and tells us whether the key was there. GetValueOrDefault() searches once and hands back default(TValue). Its second overload takes a fallback of our own, so we are not stuck with 0, false, or null.

The sections below measure that choice instead of asserting it.

Using the ContainsKey() Method

First, let’s make use of the common ContainsKey method:

var myDictionary = new Dictionary<string, int>
{
    { "alice", 1 },
    { "bob", 2 },
    { "mike", 3 }
};

var searchKey = "tom";

Console.WriteLine(myDictionary.ContainsKey(searchKey) ? myDictionary[searchKey] : default);

We construct the simple dictionary myDictionary instance with a string type for the keys and an int type for the values, one of the collections we reach for most often in .NET. We assign to the searchKey variable a key that is not present in our collection. With the use of the conditional ?: operator, we check if this key exists in the dictionary. If it does, we return the value. Otherwise, we return the default value, which in our case is the zero value.

That is the conditional operator, not the null-coalescing ?? operator. The two are easy to confuse and do different jobs: ?? supplies a value when the left-hand side is null, and a missing dictionary key never produces a null to catch.

This shape is worth it only when we want the value. When the question really is whether a dictionary key exists, ContainsKey() on its own is the right method and there is no second lookup to pay for.

Using the TryGetValue() Method

Another approach is the TryGetValue method. It is a built-in method of the Dictionary<TKey, TValue> class in C#, which takes two parameters. The first is the key to look for in the dictionary. The second is an out parameter to store the value associated with the key. It returns a Boolean value, that indicates whether the key exists in the collection.

So, let’s perform the same example with the use of the TryGetValue method:

Console.WriteLine(myDictionary.TryGetValue(searchKey, out var result) ? result : default);

Here, if the key exists in our dictionary, we return the out result variable, otherwise, we return the default type of values in our collection.

Microsoft’s CA1854 performance rule says the same thing about the shape we wrote first: “If you also call IDictionary.ContainsKey in an if clause to guard a value lookup, two lookups are performed when only one is needed.” The indexer has to find the key before it can hand back a value, so a ContainsKey guard in front of it pays for that lookup a second time. That said, in the first example, our code will perform a double look-up in the dictionary, while with the TryGetValue method it will search in the collection only once.

ContainsKey followed by the indexer hashes the key twice; TryGetValue hashes it once.

Using the GetValueOrDefault() Method

GetValueOrDefault() is not a C# language feature and has no C# version. It is a BCL extension method on IReadOnlyDictionary<TKey, TValue>, declared in System.Collections.Generic.CollectionExtensions, and it first shipped with .NET Core 2.0 and .NET Standard 2.1. What gates it is the framework we target, not the language version we compile against. The confusion is an easy one to fall into: C# 7.1 and .NET Core 2.0 shipped in the same month, August 2017.

When the method finds the key, it returns the value associated with it. When it does not, the one-argument overload returns default(TValue) and the two-argument overload returns the fallback we passed in.

Let’s use it in our example:

Console.WriteLine(myDictionary.GetValueOrDefault(searchKey));

We simply call the extension with a searchKey value as the single argument.

The second overload takes our own fallback instead:

Console.WriteLine(myDictionary.GetValueOrDefault(searchKey, -1));

Now the missing key gives us -1 rather than 0, which matters whenever 0 is a value our data could legitimately hold.

Let’s take a deeper look at the source code of both overloads, as they ship in .NET 10:

public static TValue? GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key) =>
    dictionary.GetValueOrDefault(key, default!);

public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key, TValue defaultValue)
{
    if (dictionary is null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.dictionary);
    }

    return dictionary.TryGetValue(key, out TValue? value) ? value : defaultValue;
}

GetValueOrDefault() is a thin wrapper over TryGetValue(). The one-argument overload calls the two-argument one, which calls TryGetValue() and returns the fallback when it comes back false. That is why the two of them perform almost identically in the benchmarks below, and why neither of them does more work than a single hash lookup.

What Does GetValueOrDefault() Return When the Key Is Not Found?

GetValueOrDefault(key) returns default(TValue) when the key is absent: 0 for an int, false for a bool, null for a reference type. It never throws for a missing key, and it never tells us whether the key was there.

That second point matters more than it looks. A Dictionary<string, int> holding { "bob", 0 } and one holding nothing at all both hand back 0 for "bob". Where the difference between “absent” and “stored as the default” is meaningful, GetValueOrDefault() is the wrong method and TryGetValue() is the right one.

The two-argument overload narrows the gap without closing it. GetValueOrDefault(key, fallback) returns our value rather than default(TValue), so we can pick a sentinel the data cannot produce, but a stored value equal to fallback is still indistinguishable from a missing key.

Both overloads are extension methods on IReadOnlyDictionary<TKey, TValue>, declared in System.Collections.Generic.CollectionExtensions.

ApproachHash lookupsWhen the key is missingOwn fallback?Reach for it when
dict[key]1throws KeyNotFoundExceptionnothe key must be there, and its absence is a bug
dict.ContainsKey(key) ? dict[key] : default2default(TValue)yes, in the ? :never for this job, CA1854 flags it
dict.TryGetValue(key, out var value)1returns false, sets value to default(TValue)yes, in our own branchwe need to know whether the key was there
dict.GetValueOrDefault(key)1returns default(TValue)nowe only need the value, and default is fine
dict.GetValueOrDefault(key, fallback)1returns fallbackyesdefault(TValue) is a value the data could hold

One consequence of that last line catches people out. A variable typed as IDictionary<TKey, TValue> does not get these methods at all, because IDictionary<TKey, TValue> does not implement IReadOnlyDictionary<TKey, TValue>. The error is the inference message CS0411 rather than a plain “no such method”, which makes it harder to read than it should be. The fix is to type the variable as the concrete Dictionary<TKey, TValue> or as IReadOnlyDictionary<TKey, TValue>.

A ConcurrentDictionary takes these extensions too, but for the add-if-missing job it has its own GetOrAdd() method, which stores the fallback in the dictionary rather than only returning it to us.

What Does FirstOrDefault() Return on a Dictionary?

FirstOrDefault() is a LINQ method, and a Dictionary<TKey, TValue> is an IEnumerable<KeyValuePair<TKey, TValue>>, so it compiles, and it does something quite different from what the name suggests here. It walks the dictionary looking for a matching pair, which is a full scan rather than a hash lookup, and when nothing matches it returns default(KeyValuePair<TKey, TValue>):

var pair = myDictionary.FirstOrDefault(p => p.Key == searchKey);

KeyValuePair<TKey, TValue> is a struct, so that default is not null, it is a pair whose Key and Value are both default. A pair == null check does not even compile against it: the compiler answers with error CS0019: Operator '==' cannot be applied to operands of type 'KeyValuePair<string, int>' and '<null>'. And pair.Key is null is true both when the key is missing and when someone stored a null key. For looking a key up, GetValueOrDefault() is one hash lookup and an unambiguous answer, and FirstOrDefault() is neither.

Performance Benchmarks

We will evaluate these methods to find the most efficient one in terms of speed, with the benchmark class:

[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
public class DefaultValueFromDictionaryInCSharpBenchmark
{
    private Dictionary<string, int> _myDictionary = FillDictionary();

    private readonly string _key = "number_1000";

    [Benchmark]
    public int ContainsKey()
    {
        return _myDictionary.ContainsKey(_key) ? _myDictionary[_key] : default;
    }

    [Benchmark]
    public int TryGetValue()
    {
        return _myDictionary.TryGetValue(_key, out var value) ? value : default;
    }

    [Benchmark]
    public int GetValueOrDefault()
    {
        return _myDictionary.GetValueOrDefault(_key);
    }

    private static Dictionary<string, int> FillDictionary()
    {
        var myDictionary = new Dictionary<string, int>();

        for (int i = 1; i < 10000; i++)
        {
            myDictionary.Add($"number_{i}", i);
        }
        return myDictionary;
    }
}

We create a Dictionary collection with 10000 records. Then, we assign a value to our search key that exists in our implemented collection. Finally, we have three methods to benchmark our suggested solutions accordingly.

Now, let’s take a look at the results:

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

| Method            | Mean      | Error     | StdDev    | Rank | Allocated |
|------------------ |----------:|----------:|----------:|-----:|----------:|
| TryGetValue       |  9.976 ns | 0.1061 ns | 0.0993 ns |    1 |         - |
| GetValueOrDefault | 10.024 ns | 0.1307 ns | 0.1159 ns |    1 |         - |
| ContainsKey       | 18.260 ns | 0.3597 ns | 0.3364 ns |    2 |         - |

TryGetValue and GetValueOrDefault land inside each other’s error bars, at 9.976 and 10.024 nanoseconds, and BenchmarkDotNet ranks them equal first. That is the wrapper we read above showing up in the measurement: one of them calls the other, so there is nothing left to separate them. The ContainsKey check plus the indexer takes 18.260 nanoseconds, close enough to twice the work to make the point, because it hashes the key once to ask whether it is there and again to fetch it.

Now we will run the same benchmark with a search key that does not exist in our dictionary. We assign to our key the following value:

private readonly string _key = "number_-1";

Let’s inspect the results:

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

| Method            | Mean     | Error     | StdDev    | Rank | Allocated |
|------------------ |---------:|----------:|----------:|-----:|----------:|
| TryGetValue       | 6.774 ns | 0.0580 ns | 0.0542 ns |    1 |         - |
| GetValueOrDefault | 6.980 ns | 0.1697 ns | 0.1816 ns |    1 |         - |
| ContainsKey       | 7.888 ns | 0.1543 ns | 0.1367 ns |    2 |         - |

Every method is faster on a missing key than on a present one, which is what we should expect: a failed lookup hashes the key and finds an empty bucket, with no entry to read. TryGetValue and GetValueOrDefault are again ranked equal first, at 6.774 and 6.980 nanoseconds. The ContainsKey version is still last at 7.888 nanoseconds, but its penalty nearly disappears here, because its second lookup never happens: the ?: takes the other branch and returns default instead of touching the indexer.

So the choice is not a performance choice between TryGetValue and GetValueOrDefault, and no benchmark will make it one. They cost the same because they are the same lookup. Pick TryGetValue() when the code needs to know whether the key was there, and GetValueOrDefault() when it only needs a value. The one shape worth avoiding is the ContainsKey() check before the indexer, which pays for a second lookup exactly when the key is present, which is the common case.

Do We Need the Null-Forgiving Operator With TryGetValue?

Usually not. TryGetValue() declares its out parameter as [MaybeNullWhen(false)] out TValue value, which tells the compiler the value can be null only on the false branch. Inside an if that tests the return value, the compiler already knows it is not null, so writing out result! there suppresses a warning that was never going to appear.

The ! becomes tempting somewhere else: when we use the out value outside that branch, or assign a ternary’s result straight into a non-nullable variable. Those are the cases where the warning is correct. The value really can be null, and silencing it moves a NullReferenceException from compile time to run time.

GetValueOrDefault() has the same shape from the other side. Its one-argument overload is declared to return TValue?, so on a Dictionary<string, string> the result is a string? and the null check belongs in our code rather than behind a !.

There is also a syntax detail worth knowing before we try any of this: out var value! is not legal on a declaration at all. The null-forgiving operator goes on an expression, so it belongs on the use site, as in Use(value!), and never on the out declaration itself. On a project where nullable warnings on a property are the thing biting instead, the fix is usually to give the non-nullable property a value rather than to reach for ! there either.

Conclusion

GetValueOrDefault() is the method to reach for when a missing key means a fallback rather than a failure, and its two-argument overload is what to use when default(TValue) is a value our data could legitimately hold. TryGetValue() remains the answer whenever we need to distinguish an absent key from a stored default.

A ContainsKey() check before the indexer is the one approach with nothing to recommend it: it searches the dictionary twice for an answer the other two get in one pass. Reading a value is only half the job, and there is more than one way of updating the value stored against a key once we have found it.

Tested with .NET 10 and BenchmarkDotNet 0.15.8.