MultiMap.NET 1.0.0

dotnet add package MultiMap.NET --version 1.0.0
                    
NuGet\Install-Package MultiMap.NET -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MultiMap.NET" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MultiMap.NET" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="MultiMap.NET" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MultiMap.NET --version 1.0.0
                    
#r "nuget: MultiMap.NET, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MultiMap.NET@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MultiMap.NET&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=MultiMap.NET&version=1.0.0
                    
Install as a Cake Tool

.NET NuGet NuGet Downloads License: MIT

MultiMap.NET

A thread-safe, high-performance multi-map for .NET — a dictionary that holds more than one value per key.

var map = new MultiMap<string, int>();
map.Add("primes", 2);
map.Add("primes", 3);
map.Add("primes", 5);

map["primes"];   // [2, 3, 5]
map.Count;       // 1  (keys)
map.ValueCount;  // 3  (pairs)

Why this exists

.NET already has a multi-map: ILookup<TKey, TValue>, which you get from Enumerable.ToLookup. But it is immutable — once built, you cannot add or remove anything. So everyone ends up hand-rolling Dictionary<TKey, List<TValue>> and re-writing the same "does the list exist yet?" boilerplate.

MultiMap<TKey, TValue> is the mutable counterpart, and it implements ILookup<TKey, TValue> — so it drops straight into code that already expects a lookup.

ILookup<string, Order> lookup = map;   // no conversion, no copy

Install

dotnet add package MultiMap.NET

Targets netstandard2.0 — works on .NET Framework 4.6.1+, .NET Core 2.0+, and every modern .NET.

Features

  • Multiple values per key with Add, AddRange, Remove, RemoveAll.
  • Thread-safe. Backed by ReaderWriterLockSlim, so concurrent readers never block each other. Every returned collection is a snapshot, so you can enumerate while other threads mutate.
  • Implements ILookup<TKey, TValue> for drop-in LINQ interop.
  • Insertion order preserved under each key, in both duplicate modes.
  • Optional duplicate rejection — list semantics by default, set semantics on request.
  • Custom comparers for keys and values.
  • No dependencies.

Usage

Creating

using MultiMaps;

// Duplicate values allowed under a key (default)
var map = new MultiMap<string, int>();

// Reject a value already present under that key
var unique = new MultiMap<string, int>(allowDuplicateValues: false);

// Custom comparers
var caseInsensitive = new MultiMap<string, int>(
    allowDuplicateValues: true,
    keyComparer: StringComparer.OrdinalIgnoreCase,
    valueComparer: null);

// From existing pairs
var copied = new MultiMap<string, int>(existingPairs);

Adding

map.Add("a", 1);                        // true
map.AddRange("a", new[] { 2, 3 });      // 2  (how many were added)

unique.Add("a", 1);                     // true
unique.Add("a", 1);                     // false — already there

Reading

map["a"];                     // [1, 2, 3]
map["missing"];               // []  — empty, never throws

map.TryGetValues("a", out var values);   // true, values = [1, 2, 3]

map.ContainsKey("a");         // true
map.ContainsPair("a", 2);     // true  — this exact pair
map.ContainsValue(2);         // true  — under any key

map[key] returns an empty sequence for an unknown key rather than throwing — it follows ILookup, not Dictionary.

Counting

Two different numbers, so they get two different names:

map.Count;       // number of KEYS   (this is what ILookup.Count means)
map.ValueCount;  // number of PAIRS

Removing

map.Remove("a", 2);    // true — removes ONE occurrence of that value
map.RemoveAll("a");    // 2    — removes the key and returns how many values went with it
map.Clear();

A key disappears automatically once its last value is removed.

LINQ

MultiMap is an ILookup, so it enumerates as one IGrouping per key:

foreach (var group in map)
{
    Console.WriteLine($"{group.Key}: {string.Join(", ", group)}");
}

var all = map.SelectMany(g => g).ToList();

Build one straight from a sequence, the same way you would call ToLookup:

var byFirstLetter = words.ToMultiMap(w => w[0]);
var lengthsByLetter = words.ToMultiMap(w => w[0], w => w.Length);

byFirstLetter.Add('z', "zebra");   // ...but unlike ToLookup, you can still change it

Converting out

map.Pairs();         // IEnumerable<KeyValuePair<TKey, TValue>>, one entry per pair
map.ToDictionary();  // Dictionary<TKey, List<TValue>>, an independent copy
map.Keys;            // snapshot of keys
map.Values;          // snapshot of every value

MultiValueDictionary

If you know this data structure by the name Microsoft used in the unreleased Microsoft.Experimental.Collections package, that name works too:

var dictionary = new MultiValueDictionary<string, int>();

MultiValueDictionary<TKey, TValue> derives from MultiMap<TKey, TValue> and behaves identically. Use whichever reads better.

Thread safety

Reads take a shared lock, writes take an exclusive one. Each individual operation is atomic.

Every collection handed back — map[key], Keys, Values, Pairs(), ToDictionary(), and enumeration — is a snapshot taken under the lock. That means iterating a MultiMap while another thread mutates it will never throw InvalidOperationException, unlike iterating a Dictionary.

Note that a sequence of operations is not atomic as a group. This is a race, and no collection can fix it for you:

if (!map.ContainsPair("a", 1))   // another thread can add it right here
    map.Add("a", 1);

Use allowDuplicateValues: false and let Add return false instead.

Performance notes

  • Lookup, add and remove-by-key are O(1) on the key.
  • Remove(key, value) scans that one key's values to find the occurrence: O(n) in the size of that key's bucket.
  • With allowDuplicateValues: false, each Add scans that key's bucket for a match: O(n) in the size of that bucket. This is the price of keeping insertion order; for the handful of values a typical key holds it is faster than a hash set, but it is worth knowing if you plan to put thousands of values under a single key.
  • ContainsValue scans everything: O(n) in total pairs.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 153 7/28/2026

Initial release.