Multi-value dictionary
MultiValueDictionary<TKey, TValue> maps each key to an ordered list of values - a one-to-many dictionary (a multimap). It removes the boilerplate of the common Dictionary<TKey, List<TValue>> pattern: there is no "create the list if the key is missing" dance, the indexer never returns null, and value insertion order is preserved per key.
Keys are compared with an IEqualityComparer<TKey> supplied at construction.
Pattern 1 - group values under keys
using Bodu.Collections.Generic;
var byCorrelation = new MultiValueDictionary<string, LogEntry>();
foreach (LogEntry entry in entries)
byCorrelation.Add(entry.CorrelationId, entry); // no pre-check needed
IReadOnlyList<LogEntry> forId = byCorrelation["abc-123"]; // never null
The indexer returns an empty, read-only view when the key is absent - it never throws and never returns null, so callers can iterate unconditionally:
foreach (LogEntry e in byCorrelation["missing-key"]) // safe - empty sequence
Process(e);
Pattern 2 - add many values at once
var routes = new MultiValueDictionary<string, string>();
routes.AddRange("GET", new[] { "/users", "/orders", "/health" });
routes.Add("POST", "/users");
IReadOnlyList<string> gets = routes.GetValues("GET"); // /users, /orders, /health
GetValues is equivalent to the indexer; TryGetValues returns false (and an empty list) when the key is absent, mirroring Dictionary.TryGetValue:
if (routes.TryGetValues("DELETE", out IReadOnlyList<string> deletes))
Console.WriteLine(deletes.Count);
Pattern 3 - removing values and keys
bool removedOne = routes.Remove("GET", "/health"); // removes a single value
bool removedKey = routes.RemoveAll("POST"); // removes the key and all its values
Remove(key, value) removes one matching value and returns false when the pair is absent; RemoveAll(key) drops the key entirely.
Pattern 4 - membership and counts
bool hasKey = routes.ContainsKey("GET"); // true
bool hasPair = routes.ContainsValue("GET", "/users"); // true
int totalValues = routes.Count; // total values across all keys
int distinctKeys = routes.KeyCount; // number of keys
Note the distinction: Count is the total number of values stored, while KeyCount is the number of keys.
Pattern 5 - enumerating
Enumerating the dictionary yields each key paired with its value list:
foreach (KeyValuePair<string, IReadOnlyList<string>> group in routes)
Console.WriteLine($"{group.Key}: {group.Value.Count} routes");
To iterate the flat sequence of (key, value) pairs - one row per value - use Flatten:
foreach (KeyValuePair<string, string> pair in routes.Flatten())
Console.WriteLine($"{pair.Key} → {pair.Value}");
The Keys collection exposes the distinct keys; Values and ReadOnlyValues expose the flattened values.
Pattern 6 - custom key comparison
var headers = new MultiValueDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
headers.Add("Accept", "text/html");
headers.Add("accept", "application/json");
IReadOnlyList<string> accept = headers["ACCEPT"]; // both values, case-insensitive key
Choosing a backing: List vs Set
By default the dictionary is a list multimap (Guava's ListMultimap): every added value is retained, including per-key duplicates. Passing MultiValueBacking.Set at construction switches it to an order-preserving set multimap (Guava's SetMultimap): values are deduplicated per key using an optional IEqualityComparer<TValue>, and each value keeps the position of its first occurrence.
var tags = new MultiValueDictionary<string, string>(
MultiValueBacking.Set,
StringComparer.OrdinalIgnoreCase, // key comparer
StringComparer.OrdinalIgnoreCase); // value comparer - drives deduplication
tags.Add("doc-1", "Draft");
tags.Add("doc-1", "DRAFT"); // duplicate per the value comparer - ignored
tags.Add("doc-1", "review");
Console.WriteLine(tags.Count); // 2
Console.WriteLine(tags["doc-1"][0]); // "Draft" - first occurrence and its casing win
Console.WriteLine(tags.Backing); // Set
Under Set backing the value comparer also drives Remove(key, value) and ContainsValue(key, value) - tags.Remove("doc-1", "draft") removes "Draft". Suppressed duplicate adds are not structural changes, so they do not invalidate active enumerators. Under List backing the value comparer is stored and reported by ValueComparer but not consulted; value operations use default equality, exactly as before the option existed.
The backing and value comparer are fixed at construction. Ordering guarantee: per-key values are always exposed in insertion order (first-occurrence order under Set), through the same IReadOnlyList<TValue> views as List backing. The trade-off is that each Set-backed add scans the key's existing values linearly to detect duplicates - the cost that keeps the values ordered and the list-typed view contract intact, and a good fit for the typically small per-key buckets of a multimap.
API summary
| Member | Description |
|---|---|
Add(TKey, TValue) |
Appends a value to the key's list, creating the key if needed. |
AddRange(TKey, IEnumerable<TValue>) |
Appends several values to a key. |
this[TKey] / GetValues(TKey) |
Returns the key's values as an IReadOnlyList<TValue> (empty, never null). |
TryGetValues(TKey, out IReadOnlyList<TValue>) |
Non-throwing lookup. |
Remove(TKey, TValue) |
Removes a single value; returns false if absent. |
RemoveAll(TKey) |
Removes a key and all its values. |
ContainsKey(TKey) / ContainsValue(TKey, TValue) |
Membership tests. |
Flatten() |
Enumerates (key, value) pairs, one per value. |
Keys / Values / ReadOnlyValues |
The distinct keys and the flattened values. |
Count |
Total number of values across all keys. |
KeyCount |
Number of distinct keys. |
Comparer |
The active key IEqualityComparer<TKey>. |
Backing |
The MultiValueBacking (List or Set) selected at construction. |
ValueComparer |
The value IEqualityComparer<TValue>; consulted only under Set backing. |
Clear() |
Removes all keys and values. |
Where to go next
- Choosing a collection - the full decision guide.
- Multiset - when you need value counts rather than value lists.
- Bodu.Collections.Generic API reference - full namespace overview.
- Core Foundations guides - every guide in this topic.