Skip to main content

Dictionaries

These methods cover the dictionary code you write over and over: read a key that may be missing, create a value on first use, count things, remove entries by a condition, combine two dictionaries, and turn a dictionary into an object or a URL query string. Reach for them when you would otherwise write a TryGetValue / if / assign block by hand.

The methods live in the System.Collections.Generic namespace, so you do not need an extra using directive. The exception is the ToQueryString for a NameValueCollection or string pairs, which lives in System.Collections.Specialized (see below).

At a glance​

MethodWhat it does
GetOrDefault(key)Returns the value, or default when the key is missing.
GetOrDefault(key, defaultValue)Returns the value, or the fallback you pass when the key is missing.
TryGetValue<T>Reads a value from an IDictionary<string, object?> and checks its type in one step.
GetOrAddReturns the value, or creates it with a factory, stores it and returns it.
IncrementAdds 1 (or an amount) to an int counter; a missing key starts at 0.
RemoveWhereRemoves every entry that matches a (key, value) predicate and returns the count.
AddOrUpdateStores a value for a new key, or replaces it with a computed value for an existing key.
MergeCombines two dictionaries into a new one; the second wins, or a resolver decides.
InvertSwaps keys and values into a new dictionary.
ToObject<T>Creates a T and copies each entry to the property with the same name, converting types.
ToQueryString (dictionary)Builds an encoded query string from an IDictionary<string, object?>.
ToQueryString (NameValueCollection and pairs)Builds an encoded query string from a NameValueCollection or string pairs.

Two related features are documented on other pages: the defaults + overrides operator (a C# 14 extension member that calls Merge) on Extension members, and the typo-tolerant GetClosest / GetClosestKey lookups on Fuzzy and natural matching.

Common rules​

  • A null dictionary, null key or null delegate throws ArgumentNullException with the parameter name (source, key, factory, predicate, ...).
  • Methods that return a dictionary (Merge, Invert) return a new Dictionary<TKey, TValue>. They never change their inputs.
  • Methods that change a dictionary (GetOrAdd, Increment, RemoveWhere, AddOrUpdate) change the one you call them on.
  • Formatting (ToQueryString) and conversion (ToObject<T>) use the invariant culture.

See Conventions for the rules that apply across the whole library.

Reading values​

GetOrDefault​

GetOrDefault(key) returns the stored value, or default(TValue) when the key is missing: null for reference types, 0 for numbers. It works on Dictionary<TKey, TValue>, IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue> and ConcurrentDictionary<TKey, TValue>.

Look up an order status without a KeyNotFoundException
Dictionary<string, string> statusByOrder = new Dictionary<string, string>
{
["ORD-1001"] = "Shipped",
};

string? shipped = statusByOrder.GetOrDefault("ORD-1001"); // → "Shipped"
string? unknown = statusByOrder.GetOrDefault("ORD-9999"); // → null

IReadOnlyDictionary<string, int> stockBySku = new Dictionary<string, int> { ["SKU-1"] = 12 };
int inStock = stockBySku.GetOrDefault("SKU-1"); // → 12
int missing = stockBySku.GetOrDefault("SKU-2"); // → 0
A missing key and a stored default look the same

For Dictionary<string, int>, a missing key and a key stored with 0 both return 0. When you need to tell them apart, use TryGetValue, or pass an explicit fallback with GetOrDefault(key, defaultValue).

GetOrDefault with a fallback value​

GetOrDefault(key, defaultValue) returns the stored value, or defaultValue when the key is missing. A stored value always wins, even when it equals default. It has the same four receivers as GetOrDefault(key).

Read user settings with fallbacks
Dictionary<string, string> userSettings = new Dictionary<string, string>
{
["language"] = "ar",
};

string language = userSettings.GetOrDefault("language", "en"); // → "ar"
string theme = userSettings.GetOrDefault("theme", "light"); // → "light"

ConcurrentDictionary<string, int> retryLimits = new ConcurrentDictionary<string, int>();
retryLimits["payments"] = 1;

int paymentRetries = retryLimits.GetOrDefault("payments", 5); // → 1
int emailRetries = retryLimits.GetOrDefault("email", 5); // → 5
Types that implement both dictionary interfaces

Dictionary and ConcurrentDictionary have their own overloads. A type that implements both IDictionary<TKey, TValue> and IReadOnlyDictionary<TKey, TValue> but has no overload of its own, such as SortedDictionary<TKey, TValue>, matches two overloads equally well, so the call does not compile. Store it in a variable typed as one of the interfaces, or cast it: ((IReadOnlyDictionary<string, int>)sorted).GetOrDefault("a", 0).

TryGetValue<T>​

TryGetValue<T>(key, out T value) reads a value from an IDictionary<string, object?> and checks that it is a T. It returns true only when the key exists and the value is a T. Use it for property bags, JSON-like payloads and context dictionaries where values are stored as object.

Read typed values from a request context bag
IDictionary<string, object?> context = new Dictionary<string, object?>
{
["tenantId"] = 42,
["retryCount"] = "3",
["traceId"] = null,
};

bool hasTenant = context.TryGetValue("tenantId", out int tenantId); // → true (tenantId → 42)
bool hasRetries = context.TryGetValue("retryCount", out int retryCount); // → false (retryCount → 0)
bool hasTrace = context.TryGetValue("traceId", out string? traceId); // → false (traceId → null)
bool hasUser = context.TryGetValue("userId", out string? userId); // → false (userId → null)
  • The check is a type test (value is T), not a conversion. The string "3" is not an int, so the call returns false. Use ToObject<T> when you need conversion.
  • A stored null returns false, because null is not a T.
  • The out variable must have an explicit type other than object?. With out object? value the call binds to the built-in IDictionary.TryGetValue.

Changing a dictionary in place​

GetOrAdd​

GetOrAdd(key, factory) returns the stored value. When the key is missing, it calls factory, stores the result and returns it. The factory runs only for a missing key. There are three overloads:

ReceiverFactory
IDictionary<TKey, TValue>Func<TKey, TValue> (receives the key)
IDictionary<TKey, TValue>Func<TValue>
ConcurrentDictionary<TKey, TValue>Func<TValue>
Group invoices by customer
Dictionary<string, List<string>> invoicesByCustomer = new Dictionary<string, List<string>>();

invoicesByCustomer.GetOrAdd("CUST-7", () => new List<string>()).Add("INV-100");
invoicesByCustomer.GetOrAdd("CUST-7", () => new List<string>()).Add("INV-101");

int invoiceCount = invoicesByCustomer["CUST-7"].Count; // → 2
Create a value from its key
IDictionary<string, int> lengthByCode = new Dictionary<string, int>();

int created = lengthByCode.GetOrAdd("DISCOUNT", code => code.Length); // → 8
int existing = lengthByCode.GetOrAdd("DISCOUNT", code => 0); // → 8 (factory not called)
ConcurrentDictionary

The ConcurrentDictionary overload is thread-safe. Under contention the factory may run more than once, but only one value is stored and every caller gets that value. A key-taking factory (k => ...) on a ConcurrentDictionary calls the built-in ConcurrentDictionary.GetOrAdd, which behaves the same way.

For IList<T>.GetOrAdd(selector, factory) see Lists and collections.

Increment​

Increment(key) adds 1 to the int stored under key and returns the new count. Increment(key, by) adds by, which may be negative. A missing key starts at 0.

Count page hits and adjust stock
Dictionary<string, int> hitsByPath = new Dictionary<string, int>();

int first = hitsByPath.Increment("/checkout"); // → 1
int second = hitsByPath.Increment("/checkout"); // → 2

Dictionary<string, int> stockBySku = new Dictionary<string, int> { ["SKU-1"] = 10 };

int afterSale = stockBySku.Increment("SKU-1", -4); // → 6
int restocked = stockBySku.Increment("SKU-2", 3); // → 3
warning
int only, and not thread-safe

The receiver is IDictionary<TKey, int>, so a Dictionary<string, long> has no Increment. The read and the write are two separate steps, so Increment is not thread-safe, even on a ConcurrentDictionary. For concurrent counters use ConcurrentDictionary.AddOrUpdate(key, 1, (_, count) => count + 1).

RemoveWhere​

RemoveWhere(predicate) removes every entry for which predicate(key, value) returns true and returns how many it removed. It collects the matching keys first, so it does not change the dictionary while enumerating it.

Drop expired sessions
DateTimeOffset now = new DateTimeOffset(2026, 11, 4, 12, 0, 0, TimeSpan.Zero);

Dictionary<string, DateTimeOffset> sessionExpiry = new Dictionary<string, DateTimeOffset>
{
["sess-a"] = now.AddMinutes(-5),
["sess-b"] = now.AddMinutes(30),
["sess-c"] = now.AddHours(-1),
};

int removed = sessionExpiry.RemoveWhere((sessionId, expiresAt) => expiresAt < now); // → 2
IEnumerable<string> remaining = sessionExpiry.Keys; // → ["sess-b"]

The predicate can use the key alone, for example (key, _) => key.StartsWith("tmp-", StringComparison.Ordinal). An empty dictionary, or a predicate that matches nothing, returns 0.

AddOrUpdate​

AddOrUpdate(key, addValue, updateValueFactory) stores addValue when the key is missing. When the key exists, it stores updateValueFactory(key, existingValue). It returns the value now stored. This is the ConcurrentDictionary.AddOrUpdate contract, for any IDictionary<TKey, TValue>.

Keep a running total per invoice
Dictionary<string, decimal> totalByInvoice = new Dictionary<string, decimal>();

decimal firstLine = totalByInvoice.AddOrUpdate("INV-100", 25.00m, (invoiceId, total) => total + 25.00m);
// → 25.00 (new key: addValue stored)

decimal secondLine = totalByInvoice.AddOrUpdate("INV-100", 10.50m, (invoiceId, total) => total + 10.50m);
// → 35.50 (existing key: the factory computes the new value)
  • The factory runs only when the key exists; addValue is ignored in that case.
  • On a ConcurrentDictionary variable, the same call binds to the built-in, thread-safe ConcurrentDictionary.AddOrUpdate.

Combining dictionaries​

Merge​

Merge(other) returns a new dictionary with every entry from both inputs. When a key exists in both, the value from other wins. That is the usual "defaults, then overrides" pattern for settings.

Merge(other, resolveConflict) lets you decide. For a key in both, it calls resolveConflict(key, existingValue, incomingValue) and keeps the result. The resolver runs only for keys that exist in both.

Apply user settings over defaults
Dictionary<string, string> defaults = new Dictionary<string, string>
{
["theme"] = "light",
["language"] = "en",
};
Dictionary<string, string> userSettings = new Dictionary<string, string>
{
["theme"] = "dark",
["font"] = "mono",
};

Dictionary<string, string> effective = defaults.Merge(userSettings);
// → { ["theme"] = "dark", ["language"] = "en", ["font"] = "mono" }
Add up stock from two warehouses
Dictionary<string, int> warehouseA = new Dictionary<string, int> { ["apple"] = 3, ["pear"] = 1 };
Dictionary<string, int> warehouseB = new Dictionary<string, int> { ["apple"] = 4, ["plum"] = 2 };

Dictionary<string, int> total = warehouseA.Merge(warehouseB, (sku, existing, incoming) => existing + incoming);
// → { ["apple"] = 7, ["pear"] = 1, ["plum"] = 2 }
  • Both inputs are IEnumerable<KeyValuePair<TKey, TValue>>, so you can merge any dictionary type or a list of pairs.
  • Neither input is changed. Two empty inputs give an empty dictionary.
  • The result uses the default key comparer. A case-insensitive source dictionary does not pass its comparer on, so "Theme" and "theme" become two keys.
  • With C# 14 you can write defaults + overrides instead of defaults.Merge(overrides). See Extension members.

Invert​

Invert() swaps keys and values: { "JO": "Jordan" } becomes { "Jordan": "JO" }. Use it to build a reverse lookup from a code table.

Reverse a country-code table
Dictionary<string, string> countryByCode = new Dictionary<string, string>
{
["JO"] = "Jordan",
["EG"] = "Egypt",
};

Dictionary<string, string> codeByCountry = countryByCode.Invert();
string egyptCode = codeByCountry["Egypt"]; // → "EG"
Values must be unique and not null

Values become keys, so two keys with the same value, or a null value, throw ArgumentException (ParamName "source"). An empty input returns an empty dictionary.

Converting dictionaries​

ToObject<T>​

ToObject<T>() creates a T with its parameterless constructor and copies each entry of an IDictionary<string, object?> to the public property with the same name, if it has a setter. Values are converted to the property type when needed: "42" to int, "Monday" to DayOfWeek, "7" to int?, a GUID string to Guid. Use it to map form fields, configuration rows or loosely typed payloads to a class.

Map form fields to a class
public sealed class CustomerForm
{
public string? Name { get; set; }

public int Age { get; set; }

public DayOfWeek PreferredDeliveryDay { get; set; }

public int? LoyaltyPoints { get; set; }
}

IDictionary<string, object?> fields = new Dictionary<string, object?>
{
["Name"] = "Lina",
["Age"] = "42",
["PreferredDeliveryDay"] = "Monday",
["LoyaltyPoints"] = null,
["CsrfToken"] = "abc123",
};

CustomerForm form = fields.ToObject<CustomerForm>();
// form.Name → "Lina"
// form.Age → 42
// form.PreferredDeliveryDay → DayOfWeek.Monday
// form.LoyaltyPoints → null
  • Keys match property names exactly and case-sensitively: "age" does not set Age.
  • Keys with no matching public property ("CsrfToken" above), or whose property has no setter, are ignored. A property with a private or init setter is still set. An empty dictionary gives a T with default property values.
  • Text is parsed with the invariant culture.
  • A value that cannot be converted throws InvalidCastException, for example null for an int property or an arbitrary object for an int. Text in the wrong format, such as "abc" for an int, throws FormatException.
Trimming

T is annotated with [DynamicallyAccessedMembers(PublicProperties)], so the trimmer keeps the properties that ToObject<T> sets. See Trimming and AOT.

ToQueryString on a dictionary​

ToQueryString() on an IDictionary<string, object?> builds a URL query string, without a leading ?. Keys and values are percent-encoded (a space becomes %20, & becomes %26). Values are formatted the same way on every machine:

ValueWritten as
number, other IFormattableinvariant culture: 1.5, 1000
booltrue / false
enumits name: Friday
DateTime, DateTimeOffsetISO 8601 round-trip format (o)
DateOnlyyyyy-MM-dd
TimeOnlyHH:mm:ss, plus a fraction only when there is one
nullan empty value: key=
collectionthe key repeated per item; null items are skipped
Build a search query for an orders API
Dictionary<string, object?> query = new Dictionary<string, object?>
{
["q"] = "red shoes & bags",
["minPrice"] = 19.5m,
["inStock"] = true,
["deliveryDay"] = DayOfWeek.Friday,
["coupon"] = null,
["ids"] = new int?[] { 1, null, 2 },
};

string queryString = query.ToQueryString();
Output
q=red%20shoes%20%26%20bags&minPrice=19.5&inStock=true&deliveryDay=Friday&coupon=&ids=1&ids=2
Dates use ISO 8601
Dictionary<string, object?> range = new Dictionary<string, object?>
{
["from"] = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc),
["day"] = new DateOnly(2026, 11, 4),
["at"] = new TimeOnly(14, 30),
};

string queryString = range.ToQueryString();
// → "from=2024-01-02T03%3A04%3A05.0000000Z&day=2026-11-04&at=14%3A30%3A00"
  • An empty dictionary returns "".
  • Numbers ignore the current culture: under de-DE, 1.5m is still written as 1.5.
  • A value that is not IFormattable is written with its own ToString(), which may depend on the current culture.
  • Strings inside a collection are values, not nested collections: ["tags"] = new List<string> { "a b", "c" } gives tags=a%20b&tags=c.
  • A collection inside a collection value (for example a List<int[]>) throws ArgumentException (ParamName "source") whose message names the key, because a query string cannot express it.
  • A byte[] is a collection, so each byte repeats the key.
Nested objects

This method is for flat values. For an object with nested properties, object.ToQueryString() writes Address.City=.... See Objects.

ToQueryString on a NameValueCollection or string pairs​

NameValueCollection.ToQueryString() builds a query string and repeats a key that has several values. A second overload takes any IEnumerable<KeyValuePair<string, string?>>, such as a Dictionary<string, string?> or a list of pairs with repeated keys. Both percent-encode keys and values (a space becomes %20, not +) and write a null value as key=.

Query string from a NameValueCollection
NameValueCollection filters = new NameValueCollection
{
{ "status", "open" },
{ "status", "pending" },
{ "assigned to", "a&b" },
};

string queryString = filters.ToQueryString();
// → "status=open&status=pending&assigned%20to=a%26b"
Query string from string pairs
using System.Collections.Specialized;

Dictionary<string, string?> search = new Dictionary<string, string?>
{
["name"] = "John Doe",
["company"] = null,
["q"] = "a+b",
};

string fromDictionary = search.ToQueryString();
// → "name=John%20Doe&company=&q=a%2Bb"

List<KeyValuePair<string, string?>> ids = new List<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("id", "1"),
new KeyValuePair<string, string?>("id", "2"),
};

string fromPairs = ids.ToQueryString();
// → "id=1&id=2"
  • An empty collection returns "".
  • The values are already strings, so no number or date formatting happens. Use the dictionary overload when your values are numbers, dates or collections.
warning
Add using System.Collections.Specialized;

Both overloads live in System.Collections.Specialized, which implicit usings do not import. The using you add for NameValueCollection also brings them into scope, but for a Dictionary<string, string?> or a list of pairs you must add it yourself. Without it, search.ToQueryString() still compiles: it binds to object.ToQueryString() and writes the dictionary's own properties (Count=3&Capacity=3&Keys=name&...).

See also​