C# Equality Comparison: Value vs. Reference Types
c# equality comparison: Understand how C# equality comparison works for value and reference types, including operator overloading, Equals, and GetHashCode.
When you compare two values in C#, the result depends on the type and the method you use. The default behavior is different for value types and reference types, and a simple == may not behave the way you expect. This article explains the mechanics behind c# equality comparison and how to implement consistent equality in your own types.
The Two Default Behaviors: Value vs. Reference
For value types (structs, enums, and built-in numeric types), the default Equals method performs a field-by-field comparison if the struct is marked with [StructLayout(LayoutKind.Sequential)]. Practically, most structs you define will have their fields compared. The == operator, however, is not automatically available for your custom structs; you must overload it.
For reference types (classes), the default Equals and == compare references, not the underlying data. Two distinct Person objects with identical properties are not equal unless you override Equals and GetHashCode. This distinction is the root of many subtle bugs.
Overriding Equals and GetHashCode Together
The Equals method is the basis for equality in collections like HashSet<T> and Dictionary<TKey, TValue>. When you override Equals, you must also override GetHashCode. Two objects that are equal must return the same hash code; otherwise, hash-based collections will misbehave. For example:
public class Person { public string? FirstName { get; set; } public string? LastName { get; set; } public override bool Equals(object? obj) { return obj is Person other && FirstName == other.FirstName && LastName == other.LastName; } public override int GetHashCode() { return HashCode.Combine(FirstName, LastName); } }
The Equals method uses the is pattern to check the runtime type. If you later define a subclass, this equality logic may need to account for base/subclass relationships. In most cases, you want the hash code to remain stable for the lifetime of the object, so avoid using mutable fields in GetHashCode.
Overloading operator == and operator !=
For value types, the == operator is not defined by default, so you must overload it if you want to use == on your structs. For reference types, == compares references by default, but you may overload it to compare values. When you overload ==, you should also overload !=. Consistency between ==, Equals, and CompareTo (if applicable) is essential.
public class Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; Currency = currency; } public override bool Equals(object? obj) => obj is Money other && Amount == other.Amount && Currency == other.Currency; public override int GetHashCode() => HashCode.Combine(Amount, Currency); public static bool operator ==(Money left, Money right) => left.Equals(right); public static bool operator !=(Money left, Money right) => !left.Equals(right); }
Note that left or right could be null. The Equals method on a null instance will throw a NullReferenceException. Use ReferenceEquals checks or null propagation carefully:
public static bool operator ==(Money? left, Money? right) { if (ReferenceEquals(left, null)) return ReferenceEquals(right, null); return left.Equals(right); }
Implementing IEquatable<T> for Performance
The IEquatable<T> interface provides a strongly typed Equals(T) method, avoiding boxing and runtime type checks. For value types, this can significantly reduce allocations during comparisons in generic collections. For reference types, it eliminates the downcast.
public readonly struct Point : IEquatable<Point> { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } public bool Equals(Point other) => X == other.X && Y == other.Y; public override bool Equals(object? obj) => obj is Point other && Equals(other); public override int GetHashCode() => HashCode.Combine(X, Y); public static bool operator ==(Point left, Point right) => left.Equals(right); public static bool operator !=(Point left, Point right) => !left.Equals(right); }
When you implement IEquatable<T>, you should still override the non-generic Equals and GetHashCode to maintain consistency, especially when instances can be treated as object.
ReferenceEquals: When to Use It
The ReferenceEquals method is a static method that returns true only when two references point to the exact same object. It is useful for implementing equality in base classes where you want to short-circuit if the references are the same:
public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) return true; if (obj is null) return false; // further field comparisons }
However, ReferenceEquals on value types is misleading because the arguments are boxed, so two different boxed copies of the same value are never equal. In such a context, prefer object.ReferenceEquals only on reference-type variables.
Records: Equality with Less Boilerplate
C# 9 introduced record types, which provide value-based equality out of the box for reference types (and records can also be structs). The compiler synthesizes Equals, GetHashCode, and ==/!= based on the declared properties. For example:
public record Person(string FirstName, string LastName);
This record automatically compares FirstName and LastName for equality. You can still customize the behavior by overriding the synthesized Equals or implementing IEquatable<T>. Records also provide a with expression for non-destructive mutation, which complements their equality semantics.
Records are a natural choice for data transfer objects, value objects, and any immutable model where equality by value is expected. However, if you need custom equality logic (e.g., case-insensitive comparison), you must override the synthetic methods.
Common Pitfalls and How to Avoid Them
A frequent mistake is overriding Equals but not GetHashCode, or using a mutable field in GetHashCode. This breaks hash-based collections. Another pitfall is inconsistent behavior between Equals and ==. If you overload one, you must overload the other. Also, when two objects are equal, it does not imply that their GetHashCode values are unique; they only need to be the same. Collisions are allowed, but poor distributions degrade performance.
When comparing floating-point numbers, using == is risky due to precision. Instead, compare with a tolerance:
public static bool NearlyEqual(double a, double b, double epsilon = 1e-10) { return Math.Abs(a - b) < epsilon; }
For strings, remember that == performs ordinal comparison, which is case-sensitive. If you need case-insensitive comparison, use StringComparer.OrdinalIgnoreCase or string.Equals(a, b, StringComparison.OrdinalIgnoreCase).
Performance Considerations for Equality
Equality checks in C# can have different performance characteristics. For value types, comparing with == (if overloaded) or IEquatable<T>.Equals avoids boxing and is faster than calling object.Equals. For reference types, ReferenceEquals is the fastest check, but it only determines reference identity.
In generic collections, the equality comparer is used to locate buckets. A good GetHashCode implementation that distributes values evenly reduces collisions and speeds up operations like HashSet<T>.Contains and Dictionary<TKey,TValue>.Lookup. The HashCode.Combine method provides a reliable way to produce a compound hash code.
For large or deeply structured types, manual equality checks with fields can become verbose. Use of records or generated equality can reduce maintenance overhead. If performance is critical, consider implementing IEquatable<T> and avoiding object allocation during comparison.
Where Equality Breaks with Inheritance
Equality in a class hierarchy is tricky. If the base class overrides Equals using its own fields, and the derived class adds new fields, then a base-class reference to a derived object will not consider the derived fields. Symmetry may break: baseObj.Equals(derivedObj) and derivedObj.Equals(baseObj) could return different results if only one side checks the runtime type.
A common solution is to make the base type abstract or sealed, or to check GetType() equality in Equals. However, this can break Liskov substitution. A better approach is to design for value semantics only in sealed types, or to use records which handle this more predictably with the EqualityContract.
When using inheritance, define a protected virtual bool Equals(object? obj, bool compareBase) pattern, or use the IEquatable<T> interface separately for each generation of the hierarchy.
Choosing the Right Equality Strategy
There is no universal "best" way; the right approach depends on the type's role. For immutable data carriers, records offer the least boilerplate and consistent value equality. For performance-sensitive structs, implement IEquatable<T> and overload operators. For domain entities that have an identity (like database IDs), reference or ID-based equality may be the correct model—overriding Equals to compare a primary key, rather than all fields, can be more meaningful.
In summary, when selecting a strategy, consider the intended semantics (value vs. identity), the type's mutability, the need for hashing, and the performance context. A clear decision now prevents subtle bugs later.