Compares the two sorted key-value collections in .NET: red-black tree versus sorted arrays, costs, memory and use cases.
Both keep keys sorted and give O(log n) lookup, but they are built differently. SortedDictionary is a red-black tree: O(log n) insert and remove anywhere, but one node object per entry, so more memory and worse cache locality. SortedList is two sorted arrays (keys and values) with binary search: lookup is O(log n) and memory is compact, but inserting or removing in the middle is O(n) because elements shift. Appending keys in ascending order is cheap. SortedList also allows access by index (Keys[i], Values[i]). Choose SortedList for data loaded once or in order and read often; SortedDictionary for frequent random inserts and removes; plain Dictionary when you do not need order.
Both keep keys sorted by a comparer, but they use different data structures. SortedDictionary<TKey,TValue> is a red-black tree: O(log n) insert, remove and lookup, with one heap node per entry. SortedList<TKey,TValue> is two parallel sorted arrays (keys and values): O(log n) lookup by binary search, but O(n) insert and remove because elements shift. Then there is plain Dictionary, which is faster than both when you don't need order.
SortedDictionary<TKey,TValue> wraps a SortedSet<KeyValuePair<TKey,TValue>> — a red-black tree of nodes, each holding a key/value pair, left/right child references and a colour bit.
SortedList<TKey,TValue> holds TKey[] keys and TValue[] values of the same length, kept sorted by key. Lookup is Array.BinarySearch on the keys array; inserting at position i shifts everything after i in both arrays.
| Operation | SortedDictionary |
SortedList |
Dictionary |
|---|---|---|---|
| Lookup by key | O(log n) | O(log n) | O(1) avg |
| Insert random key | O(log n) | O(n) (shift) | O(1) avg |
| Insert in ascending order | O(log n) | O(1) amortised (append) | O(1) avg |
| Remove | O(log n) | O(n) | O(1) avg |
| Enumerate in key order | O(n), pointer-chasing | O(n), sequential arrays (fast) | Unordered |
| Access by index (k-th key) | Not supported (O(n) via enumeration) | O(1) (Keys[i], GetKeyAtIndex(i), IndexOfKey) |
Not supported |
| Memory per entry | High (node object, ~40+ bytes overhead) | Low (just array slots) | Medium (entry struct + bucket) |
| Build from data | O(n log n) | O(n log n) via sorted input; O(n²) if inserted randomly one by one | O(n) |
Dictionary (or FrozenDictionary for read-only). Sort only when you output: dict.OrderBy(kv => kv.Key).SortedDictionary.SortedList — less memory, faster enumeration, and index-based access for "k-th smallest" or paging.BinarySearch, or SortedList populated from sorted data; or FrozenDictionary if ordering isn't needed at lookup time.SortedList (binary search for the bounds, then index range) or SortedSet<T>.GetViewBetween for tree-based sets. SortedDictionary doesn't expose a range view directly.A market-data component maintained price levels (price → quantity) for an order book. Levels change constantly at random positions, so SortedDictionary<decimal, decimal> (with a descending comparer for bids) gives O(log n) updates and in-order iteration for the top levels.
By contrast, a holiday calendar (date → holiday name) for Australian states is loaded once at start-up and queried thousands of times: SortedList<DateOnly, string> built from sorted data uses less memory and lets me find "the next public holiday after date X" by binary searching the keys (IndexOfKey/BinarySearch on Keys) and taking the next index.
A team used SortedList<DateTime, Reading> to store sensor readings, inserting as messages arrived. Messages came out of order from multiple devices, so most inserts landed in the middle and shifted large arrays; with ~500,000 entries per device, CPU spiked and ingestion fell behind. Switching to SortedDictionary fixed ingestion (O(log n) inserts), and for the reporting path we copied into arrays once per batch for fast sequential reads. Profiling with dotnet-trace showed Array.Copy inside SortedList.Insert as the smoking gun.
Checks understanding of the implementation behind each type and the ability to choose based on workload.
SortedList, memory and cache trade-offs, index access, and when a plain Dictionary is better.SortedList allows duplicates, or uses sorted collections when order is not needed.A red-black tree is a binary search tree with extra rules that keep it approximately balanced:
These rules guarantee the longest path is at most twice the shortest, so height is O(log n). Inserts and deletes restore the rules with recolouring and at most a few rotations — O(log n) worst case.
.NET's implementation (SortedSet<T>, used by SortedDictionary) uses top-down insertion and deletion, splitting 4-nodes on the way down, and each node is a class:
internal sealed class Node { public T Item; public Node? Left; public Node? Right; public NodeColor Color; }
For SortedDictionary<int,int>, each entry costs a node object (~48 bytes on 64-bit including header and the KeyValuePair), and traversal follows references scattered across the heap — more cache misses than array-based structures.
SortedList internalsprivate TKey[] keys;
private TValue[] values;
private int _size;
private readonly IComparer<TKey> comparer;
TryGetValue: Array.BinarySearch(keys, 0, _size, key, comparer) — O(log n) comparisons, and binary search over contiguous memory is cache-friendly for small-to-medium sizes.Add(key, value): binary search for the insertion point; if the key exists, throw; otherwise Array.Copy both arrays one slot to the right from that point, then write. Inserting at the end (keys arriving in ascending order) has nothing to shift.List<T>.Keys and Values return IList<T> views supporting index access; GetKeyAtIndex(i) / GetValueAtIndex(i) / SetValueAtIndex(i, v) (.NET 7+) give direct access.Both types take an IComparer<TKey>:
Comparer<TKey>.Default, which uses IComparable<T>. For strings this is culture-sensitive — sorted order and even key equality (Compare == 0) depend on the current culture. Pass StringComparer.Ordinal (or OrdinalIgnoreCase) for identifiers.Equals. A comparer that ignores part of the key will merge distinct keys.Comparer<T>.Create((a, b) => b.CompareTo(a)).For n = 100,000 random integer keys (rough relative numbers on a modern CPU):
| Operation | Dictionary |
SortedDictionary |
SortedList |
|---|---|---|---|
| Build by random inserts | 1× | ~10× | ~100×+ (shifting) |
| Build from sorted input | 1× | ~8× | ~2× |
| Lookup | 1× | ~5–10× | ~3–5× |
| Full ordered enumeration | n/a | ~3–5× slower than SortedList | fastest |
| Memory | 1× | ~2–3× | ~0.5–0.7× |
Always benchmark with your key type and comparer — string comparisons with culture rules can dominate everything.
BinarySearch: smallest and fastest for read-only data; ~index gives the insertion point for range queries.ImmutableSortedDictionary<TKey,TValue>: AVL-tree-based persistent map for sharing snapshots across threads.PriorityQueue when you only need the minimum/maximum repeatedly, not full ordering.SortedList and SortedDictionary.GC.GetTotalMemory or a memory profiler.SortedList using binary search on Keys.SortedDictionary<string, int> with the default comparer under tr-TR culture and insert "I" and "ı".// Tiered rates loaded once in ascending order: SortedList
var tiers = new SortedList<decimal, decimal>
{
[0m] = 0.10m,
[1_000m] = 0.08m,
[10_000m] = 0.05m,
};
decimal RateFor(decimal amount)
{
// floor lookup: largest key <= amount, using binary search on Keys
IList<decimal> keys = tiers.Keys;
int lo = 0, hi = keys.Count - 1, best = 0;
while (lo <= hi)
{
int mid = lo + (hi - lo) / 2;
if (keys[mid] <= amount) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
return tiers.Values[best];
}
// Frequent random inserts and removes: SortedDictionary
var orderBook = new SortedDictionary<decimal, int>(
Comparer<decimal>.Create((a, b) => b.CompareTo(a))); // highest price first
orderBook[101.5m] = 300;
orderBook[99.0m] = 150;
orderBook.Remove(101.5m);
foreach (var (price, qty) in orderBook)
Console.WriteLine($"{price}: {qty}"); // key order
// Index access only on SortedList
Console.WriteLine(tiers.Keys[^1]); // highest threshold
Console.WriteLine(tiers.IndexOfKey(1_000m)); // 1
What happens: SortedDictionary chosen "because sorted is nicer".
Why it is wrong: O(log n) operations and more memory instead of O(1) Dictionary.
Do instead: use Dictionary and sort only when producing output.
SortedListWhat happens: a SortedList filled from unordered data or live, out-of-order events.
Why it is wrong: each insert shifts both arrays — O(n) per insert, O(n²) overall.
Do instead: use SortedDictionary for random inserts, or sort the data first and build once.
What happens: new SortedDictionary<string, T>().
Why it is wrong: the default comparer is culture-sensitive: ordering and key equality vary by server culture.
Do instead: pass StringComparer.Ordinal or OrdinalIgnoreCase.
What happens: keys compared by a partial field (e.g. only the date part of a timestamp).
Why it is wrong: distinct keys are treated as duplicates — Add throws or values overwrite.
Do instead: make comparers total over the key, adding tie-breakers.
SortedDictionaryWhat happens: ElementAt(i) used for paging through a SortedDictionary.
Why it is wrong: O(i) per call because it enumerates from the start — O(n²) for full paging.
Do instead: use SortedList (O(1) index access) or enumerate once.
What happens: millions of entries in SortedDictionary in a memory-constrained container.
Why it is wrong: node objects add tens of bytes per entry and GC tracing cost.
Do instead: consider sorted arrays, SortedList (if inserts are ordered), or pushing ordering to the database.
OrderBy repeatedly instead of a sorted structureWhat happens: a dictionary is sorted with LINQ on every request. Why it is wrong: O(n log n) work and allocations per request. Do instead: maintain a sorted structure or cache the sorted result if data changes rarely.
What happens: a static SortedDictionary updated from multiple threads.
Why it is wrong: not thread-safe; tree rotations under concurrent writes corrupt the structure.
Do instead: lock, or use immutable sorted collections with atomic swaps.
ContainsKey + indexerWhat happens: double lookups on sorted collections.
Why it is wrong: each lookup is O(log n), doubling cost.
Do instead: use TryGetValue.
What happens: expecting a sorted collection to keep insertion order.
Why it is wrong: sorted collections order by key; insertion order is lost.
Do instead: use OrderedDictionary<TKey,TValue> (.NET 9) for insertion order.
Seniors answer with the access pattern first and back it with measurement, because constant factors and cache behaviour often beat big-O for small sizes.
| Access pattern | Choice | Reason |
|---|---|---|
| Load once, read often, need order | SortedList |
Compact, cache-friendly, index access |
| Random inserts and deletes, need order | SortedDictionary |
O(log n) updates |
| Need ranges, floor or ceiling | SortedSet<T>.GetViewBetween or binary search on SortedList.Keys |
Neither map has a range API |
| Order only for display | Dictionary + sort at output |
O(1) lookups |
| Read-only table | FrozenDictionary + pre-sorted key array |
Fastest reads |
They also note that SortedList memory can be trimmed with TrimExcess after loading, while the tree's per-node overhead cannot be reduced.
A map (associative array, dictionary) stores key → value pairs with unique keys. Choose the implementation by access pattern:
Dictionary<K,V>: hash table, O(1) average, unordered. The default.SortedDictionary<K,V>: red-black tree, O(log n), sorted by key, fast inserts/removes.SortedList<K,V>: two sorted arrays, O(log n) lookup but O(n) insert/remove; compact, indexable. Good for small or build-once sorted maps.OrderedDictionary<K,V> (.NET 9+): keeps insertion order with index access.ConcurrentDictionary<K,V>: thread-safe, lock-free reads.FrozenDictionary<K,V> (.NET 8+): built once, fastest reads.ImmutableDictionary<K,V>: immutable snapshots with cheap modified copies.ILookup<K,V> / Dictionary<K, List<V>>: multi-map (one key, many values).Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.
How to tell it (STAR)
**Situation:** Our pricing service at [company] was using far more memory than expected and its tier lookup was hot in CPU profiles. **Task:** I was asked to reduce memory and latency without changing any prices. **Action:** A heap dump showed millions of tree nodes from `SortedDictionary`. The tables were rebuilt once a night and only read afterwards, and the lookup enumerated the whole tree to find the floor tier. I changed them to `SortedList` loaded from already-sorted query results, trimmed the capacity, and replaced the enumeration with a binary search over the keys. I verified identical prices by replaying [a day] of requests against both versions. **Result:** Memory on the pods dropped by [about X%] and p99 lookup time fell from [X] to [Y]. I added a benchmark and documented the decision so the next table type would be chosen by access pattern.
Last updated 30 Oct 2026.