Module 1 · Equality, comparers and reliable keys · Lesson 2 of 3
Choosing Ordinal Comparers for Dictionary and HashSet
Watch
Learning outcome
By the end of this lesson, you should be able to choose a StringComparer for Dictionary<string, TValue> and HashSet<string> based on the actual meaning of the key. You should also be able to explain that the comparer is not a cosmetic detail: it defines both lookup behavior and what counts as a duplicate element.
Intuition
Hash collections do two jobs at once: they compute a hash code and they decide whether two keys are equal. For strings, that second rule is controlled by the comparer you pass to the collection.
If you want two keys to be different only when their characters differ exactly, use an ordinal comparer. If you want case-insensitive behavior, use StringComparer.OrdinalIgnoreCase.
The key interview idea is this: the comparer policy belongs to the collection, not to the string itself. The same two strings can be considered equal in one dictionary and distinct in another.
Deep dive
For most backend identifiers, machine-generated names, route segments, protocol tokens, and configuration keys, ordinal comparison is the safest default because it compares the underlying UTF-16 code units directly rather than applying culture rules. This avoids dependence on the current user locale. Case-insensitive Unicode matching can still evolve with runtime Unicode data.
Be precise about the default: Dictionary<string, TValue> and HashSet<string> use EqualityComparer<string>.Default unless you supply a comparer. In practice, that default behaves like ordinal case-sensitive equality for strings, but it is better to choose the comparer explicitly when the intent matters.
The most important rule is consistency: if your dictionary uses StringComparer.OrdinalIgnoreCase, then both lookup and uniqueness are case-insensitive. You do not get to choose case-insensitive lookup and case-sensitive uniqueness separately inside the same collection.
HashSet<string> follows the same policy model as Dictionary<string, TValue>:
Addreturnstruewhen a new element was inserted.
Addreturnsfalsewhen the comparer says the element is already present.
Containsuses the comparer, not a raw==comparison.
Removealso uses the comparer.
That means the comparer changes the definition of “same key” for the entire collection.
Failure modes
Common mistakes in interviews and production code include:
- Using the default comparer without checking semantics The default comparer for string keys is not something to guess about in important code. Choose the comparer explicitly when the key meaning matters.
- Using culture-sensitive comparison for identifiers Culture-aware comparison is for human text, not protocol keys. It can make lookups depend on the current culture, which is usually a bug for backend systems.
- Assuming comparer choice affects only lookup, not uniqueness In
HashSet<string>, comparer policy directly controls what duplicates exist. InDictionary<string, TValue>, it controls whether a later assignment replaces an existing entry.
- Mixing comparer policy across layers If a service normalizes keys one way but the collection compares them another way, you can get invisible collisions or failed lookups.
- Treating ordinal as “ASCII only”
Ordinal means code-unit comparison, not limited to ASCII. It simply does not apply linguistic rules.
Interview drill
Answer first, then check the reasoning against the program below.
- Why use
StringComparer.OrdinalIgnoreCasefor HTTP header names?
HTTP field names ignore letter case. In this example, Content-Type and content-type therefore identify one name. Do not apply that rule automatically to field values or unrelated identifiers.
- What changes in a case-insensitive set?
For api followed by API, the first Add returns True, the second returns False, and the count is 1.
- How can the same two strings behave differently in two dictionaries?
The ordinal dictionary keeps values 1 and 2 in two entries. The ignore-case dictionary treats the second assignment as an update; one entry remains with value 2.
- When is current-culture comparison a poor fit?
For this header-name collection, changing the reader's language should not change which entry the request finds. Use the protocol's matching rule.
- Why does the comparer affect both lookup and uniqueness?
If API finds the entry inserted as api, adding API must also recognize that existing entry. The two operations cannot use contradictory definitions of a duplicate.
- Why choose
Ordinalexplicitly if the string default is already case-sensitive?
In this listing it documents the intended contrast with the ignore-case collection. It is an explicit policy choice, not an extra normalization step.
Revision checklist
- I can explain that a collection comparer defines key equality.
- I can choose ordinal comparers for protocol, config, and identifier keys.
- I understand that
OrdinalIgnoreCasemakes both lookup and duplicate detection case-insensitive.
- I know that culture-aware comparison is usually not appropriate for backend keys.
- I can describe how
Dictionary<string, TValue>andHashSet<string>use the same comparer policy model.
Worked executable example
The example below demonstrates how the same pair of strings behaves differently under different comparers. It shows that Dictionary replacement and HashSet uniqueness both follow the comparer policy.
Code walkthrough
The program builds two dictionaries and two sets with different comparer policies.
- With
StringComparer.Ordinal,"api"and"API"are different keys.
- With
StringComparer.OrdinalIgnoreCase, those same strings collapse to one logical key.
- The
Countvalues demonstrate uniqueness rules.
- The
ContainsKeychecks demonstrate lookup rules.
- The
HashSet.Addresults show whether the second value was inserted or rejected as a duplicate.
The final PASS is printed only after both pairs satisfy the count, insertion, lookup, and replacement checks. Notice that the code does not rely on numeric hash codes or iteration order. That is intentional: interview-relevant behavior here is semantic, not dependent on implementation details.
Executable code examples
Run this example
Use the .NET 10 SDK. In a new empty folder, save the project below as Example.csproj and the listing as Program.cs. Run dotnet run --project Example.csproj.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Comparer policy changes lookup and uniqueness
Program.cs
using System;
using System.Collections.Generic;
internal static class Program
{
private static void Main()
{
var cases = new[] { ("api", "API"), ("Content-Type", "content-type") };
var ordinalDict = new Dictionary<string, int>(StringComparer.Ordinal);
var ignoreCaseDict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var ordinalSet = new HashSet<string>(StringComparer.Ordinal);
var ignoreCaseSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (first, second) in cases)
{
ordinalDict.Clear();
ignoreCaseDict.Clear();
ordinalSet.Clear();
ignoreCaseSet.Clear();
ordinalDict[first] = 1;
ordinalDict[second] = 2;
ignoreCaseDict[first] = 1;
ignoreCaseDict[second] = 2;
bool ordinalAddedFirst = ordinalSet.Add(first);
bool ordinalAddedSecond = ordinalSet.Add(second);
bool ignoreCaseAddedFirst = ignoreCaseSet.Add(first);
bool ignoreCaseAddedSecond = ignoreCaseSet.Add(second);
bool policyChecksPass =
ordinalDict.Count == 2 && ignoreCaseDict.Count == 1 &&
ordinalSet.Count == 2 && ignoreCaseSet.Count == 1 &&
ordinalAddedFirst && ordinalAddedSecond &&
ignoreCaseAddedFirst && !ignoreCaseAddedSecond &&
ordinalDict.ContainsKey(second) && ignoreCaseDict.ContainsKey(second) &&
ordinalDict[first] == 1 && ignoreCaseDict[first] == 2;
if (!policyChecksPass)
{
Console.Error.WriteLine("FAIL: comparer policy check failed.");
Environment.ExitCode = 1;
return;
}
Console.WriteLine($"Pair: '{first}' vs '{second}'");
Console.WriteLine($"Ordinal dictionary count = {ordinalDict.Count}");
Console.WriteLine($"OrdinalIgnoreCase dictionary count = {ignoreCaseDict.Count}");
Console.WriteLine($"Ordinal set added first = {ordinalAddedFirst}");
Console.WriteLine($"Ordinal set added second = {ordinalAddedSecond}");
Console.WriteLine($"OrdinalIgnoreCase set added first = {ignoreCaseAddedFirst}");
Console.WriteLine($"OrdinalIgnoreCase set added second = {ignoreCaseAddedSecond}");
Console.WriteLine($"Ordinal lookup for second key exists = {ordinalDict.ContainsKey(second)}");
Console.WriteLine($"OrdinalIgnoreCase lookup for second key exists = {ignoreCaseDict.ContainsKey(second)}");
Console.WriteLine($"Ordinal stored value for first key = {ordinalDict[first]}");
Console.WriteLine($"OrdinalIgnoreCase stored value for first key = {ignoreCaseDict[first]}");
Console.WriteLine();
}
Console.WriteLine("PASS: comparer policy controls both lookup and uniqueness.");
}
}Trace one pair before running
For api and API, first trace the assignments: ordinal stores two entries; ignore-case updates the first entry to 2. Then trace Add: the ordinal set accepts both strings; the ignore-case set rejects the second. Both lookup lines are True, so those lines alone do not demonstrate the policy difference. Read them together with counts, second-insertion results and the stored value for the first key.
Expected output
Both pairs must produce these results. If a policy check fails, the program exits with code 1 and writes a FAIL message instead of printing PASS.
Pair: 'api' vs 'API' Ordinal dictionary count = 2 OrdinalIgnoreCase dictionary count = 1 Ordinal set added first = True Ordinal set added second = True OrdinalIgnoreCase set added first = True OrdinalIgnoreCase set added second = False Ordinal lookup for second key exists = True OrdinalIgnoreCase lookup for second key exists = True Ordinal stored value for first key = 1 OrdinalIgnoreCase stored value for first key = 2 Pair: 'Content-Type' vs 'content-type' Ordinal dictionary count = 2 OrdinalIgnoreCase dictionary count = 1 Ordinal set added first = True Ordinal set added second = True OrdinalIgnoreCase set added first = True OrdinalIgnoreCase set added second = False Ordinal lookup for second key exists = True OrdinalIgnoreCase lookup for second key exists = True Ordinal stored value for first key = 1 OrdinalIgnoreCase stored value for first key = 2 PASS: comparer policy controls both lookup and uniqueness.
Quick reference: predict the operation, then the policy
Use the lesson's ordinal and ignore-case comparison side by side. For a dictionary already containing job with value 4 under StringComparer.OrdinalIgnoreCase:
map["JOB"] = 9updates the matching entry. Count stays 1;map["JoB"]reads 9.map.Add("JOB", 9)rejects the duplicate withArgumentException; the existing value remains 4 if the exception is caught.map.TryAdd("JOB", 9)returns false and leaves the value at 4.
These are three alternative operations from the same starting state, not a sequence. The comparer decides whether a match exists; the operation decides what to do with that match.
For an ignore-case set containing job, Add("JOB") returns false, Contains("JoB") returns true, and Remove("JOB") removes that entry.
A lookup check that shows the difference
The main example inserts both spellings before checking the second. Its counts and replacement results correctly show the contrast, but both lookup checks succeed. To isolate lookup alone, insert only one spelling:
var exact = new HashSet<string>(StringComparer.Ordinal) { "job" };
var folded = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "job" };
Console.WriteLine(exact.Contains("JOB"));
Console.WriteLine(folded.Contains("JOB"));Expected output:
False True
Three interview boundaries
- Choose the rule from the data's contract. HTTP field names ignore case; that does not establish the rule for field values or every route, account name, or token.
- Ignoring case is not trimming. In the fixture above, adding
"job "tofoldedsucceeds because the trailing space remains part of the key. - Rebuilding a dictionary under a different policy may merge previously distinct keys. If an ordinal source contains both
jobandJOB, the constructor taking that source plusOrdinalIgnoreCasethrows for the duplicate. Decide how such conflicts should be handled before migrating data.
Interview answer in twenty seconds
“I choose a comparer from the key's meaning and use the same policy wherever those keys must agree. Then I read the operation: assignment can replace a matching value, Add rejects a duplicate, and TryAdd reports failure without replacing it. I test lookup using one stored spelling so the case rule is visible.”
Primary API checks: Dictionary.Add, Dictionary.TryAdd. These examples extend the lesson's existing collection-policy trace; they do not require changing its current code or analogy.
Optional video: collection comparers
Watch the 7:54–9:29 collection-comparer chapter of Michael Jolley: Stop Using ToLower() for Comparisons!. The free external segment demonstrates StringComparer.OrdinalIgnoreCase with dictionaries and sets. Use this lesson for the precise rule: ordinal comparison uses UTF-16 code units; choose case sensitivity from the key’s contract.
Analogy
Imagine two stockroom counters recording parcel labels. Counter A keeps BoxA and boxa as two entries. Counter B treats the letter-case difference as irrelevant and keeps one matching entry. Each clerk uses the same rule when filing a new label and when searching for it.
Mapping. Each register is a collection; the counter's matching rule is its comparer. The rule changes which labels count as duplicates without changing the labels themselves.
Where it stops. These simple labels illustrate matching policy, not all Unicode case behavior. The story does not explain how equal keys are hashed or how values are stored; use the code and walkthrough in the notes for those details.