A collection can contain two different object instances that represent the same product code. Whether they should count as one item depends on the equality rule. Hash-based collections also need a compatible hash rule to find the item again. If equality says two values are the same but their hashes disagree, lookup behavior can become incorrect. If a key changes after insertion, even a well-written hash implementation can no longer find it as expected.
This guide uses .NET 8 and C# 12. You will define an immutable product-code value with explicit case-insensitive equality, test it in a HashSet, and choose a collection comparer for string keys. The examples distinguish identity, equality and ordering rather than treating them as interchangeable.
Identity and value equality answer different questions
Reference identity asks whether two references point to the same object. Value equality asks whether two values represent the same domain value under a chosen rule. Two separately constructed customer objects can have different identity while referring to the same customer identifier. Conversely, matching display names do not necessarily identify the same customer.
Ordinary classes inherit reference-oriented equality unless they implement a different policy. Records synthesize value equality from their declared data, but that policy still needs to match the domain. A record with a list property does not automatically make the list's contents deeply equal. Think about which fields identify the value and whether those fields are stable. A price quote's timestamp may be part of its value; a product code's display capitalization may not be.
Equality must satisfy a coherent contract
A practical equality relation should be reflexive, symmetric and transitive, and should remain consistent while the relevant state is unchanged. If one object considers another equal but the reverse comparison disagrees, a collection's results become difficult to reason about. Inheritance can complicate symmetry when derived types add identity-bearing data, which is one reason a small sealed value type or sealed class can be easier to design.
IEquatable<T> provides typed equality for generic callers. Overriding Equals(object?) keeps ordinary object-based comparison consistent with that typed rule. Operator overloads, if provided, should follow the same meaning. Do not implement one rule for ==, another for Equals, and a third for a collection. Tests should exercise equal values constructed independently, different values, null where applicable, and symmetry in both argument orders.
Equal values must have equal hashes
A hash code is a compact lookup aid, not a unique identity. Different values can have the same hash, and a collection resolves such collisions by checking equality. The essential implication is one-way: values that compare equal must produce the same hash code under the same policy. A hash does not need to be unique and should not be persisted as a durable identifier.
If equality ignores case, hashing must ignore case through the same comparer. If equality uses several fields, combine compatible hashes for those same fields. Keep identity-bearing fields immutable while the object is a key in a Dictionary or an element of a HashSet. Changing those fields can move its conceptual bucket without updating the collection's internal storage. Remove and reinsert under controlled conditions, or preferably use an immutable key.
Comparers let a collection choose a policy
A collection can accept an IEqualityComparer<T> rather than requiring the type itself to have only one global meaning. This is especially useful for strings. A protocol token can use ordinal comparison, while a user-facing sort can use an appropriate linguistic rule. A case-insensitive dictionary should receive StringComparer.OrdinalIgnoreCase at construction rather than repeatedly lowercasing lookup strings.
Equality comparison and ordering comparison have different interfaces and purposes. IEqualityComparer<T> supplies equality and hashing. IComparer<T> supplies ordering for sorting or ordered collections. If an ordered set's comparer returns zero, it treats the keys as equivalent for that collection. Make sure this is the intended key policy. A comparer that considers only a person's age would collapse different people of the same age in such a set.
Review mutable and composite keys carefully
An immutable record containing primitive immutable components can make an effective composite key. A record containing mutable reference objects requires more thought: its generated equality normally delegates to the components' equality behavior, and those components may change or retain identity-based comparison. Do not assume the word record implies deep immutability or deep structural equality.
Keep test assertions focused on observable behavior. Insert one key, look up an independently constructed equal key, and verify that unequal keys can coexist. A test that merely compares a hash against a hard-coded number does not verify correctness and can break across runtime versions. Test the equality-to-hash implication, collision handling through real collection operations, and the chosen case or culture policy instead.
Official references
C# Equality, Hashing and Comparers Examples
Each example shows the code, the output it produces and what each part does.
A stable product-code value
The complete program gives product codes an ordinal case-insensitive policy. The property is read-only, so the value cannot silently change after insertion.
The second insertion is rejected because the equality policy treats the two spellings as the same code. The example does not print a hash value because the numeric result is not a stable interchange contract.
using System;
using System.Collections.Generic;
var codes = new HashSet<ProductCode>();
Console.WriteLine(codes.Add(new ProductCode("ab-7")));
Console.WriteLine(codes.Add(new ProductCode("AB-7")));
Console.WriteLine(codes.Count);
public sealed class ProductCode : IEquatable<ProductCode>
{
private static readonly StringComparer Comparer =
StringComparer.OrdinalIgnoreCase;
public string Value { get; }
public ProductCode(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("A code is required.", nameof(value));
Value = value;
}
public bool Equals(ProductCode? other) =>
other is not null && Comparer.Equals(Value, other.Value);
public override bool Equals(object? obj) =>
obj is ProductCode other && Equals(other);
public override int GetHashCode() =>
Comparer.GetHashCode(Value);
}True
False
1C# Equality, Hashing and Comparers in Real Projects
Worked exercise: case-insensitive inventory lookup
The task is to load an inventory count under a product-code string and retrieve it with a differently capitalized input. Use a comparer at the collection boundary, preserve the original key text, and report a missing product through TryGetValue. No manual transformation should be required at each lookup.
using System;
using System.Collections.Generic;
var inventory = new Dictionary<string, int>(
StringComparer.OrdinalIgnoreCase)
{
["Bolt-M8"] = 25
};
foreach (string requested in new[] { "BOLT-M8", "nut-m8" })
{
if (inventory.TryGetValue(requested, out int count))
Console.WriteLine($"Available: {count}");
else
Console.WriteLine("Unknown product");
}Available: 25
Unknown productThe policy lives in one place, so insertion and lookup use the same rule. If the application later stores these identifiers in a database, its uniqueness and comparison rules should be aligned with this application contract rather than assumed to match automatically.
C# Equality, Hashing and Comparers FAQ
Can unequal values share a hash code?
Yes. Hash codes have a limited size, so collisions are inevitable. A correct hash-based collection checks equality among candidate entries after using the hash to narrow the search. Your implementation should distribute ordinary values reasonably, but correctness depends on equal values having equal hashes. Never treat a hash alone as proof of identity or use it as a permanent external record identifier.
Why is changing a dictionary key dangerous?
The collection places the entry according to the key's hash at insertion. If a field used by equality and hashing changes, a later lookup can search a different location. The object still exists in the collection, but lookup under either interpretation can become confusing. Prefer immutable key data. Updating the value associated with a stable key is a different operation and is normally safe.
Do records solve every equality problem?
Records provide convenient generated value equality, but the fields and component types still define what equality means. Lists and other mutable reference components do not automatically become deeply compared or immutable. Check whether every component belongs in the identity policy and whether it can change. For domain-specific comparison rules, an explicit comparer or a carefully designed value type may be more appropriate.
Should string identifiers use culture-sensitive equality?
Internal tokens usually benefit from an ordinal rule because their meaning should not change with the user's language. A case-insensitive protocol can use ordinal-ignore-case when that matches its specification. Human-language search or sorting may intentionally use a culture. Keep these responsibilities separate and document the identifier alphabet, case policy and database constraints so the full system agrees.
C# Equality, Hashing and Comparers Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
An inventory dictionary should ignore code capitalization using an ordinal rule. Which design is coherent?
C# Equality, Hashing and Comparers Quiz
2 questions to check what you learned.
Which implication must a correct equality and hashing implementation satisfy?
- Different values must always have different hashes.
- Equal values must have equal hashes under the same policy.
- Equal hashes must prove equality.
- Hash codes must remain identical across runtime versions.
Why should a HashSet key's equality-bearing fields remain stable?
- HashSet recomputes and relocates the stored key after every field assignment.
- HashSet automatically copies every field on insertion.
- Mutation can change its hash and invalidate the collection's lookup assumptions.
- A successful insertion guarantees every later lookup will find a mutated key.