BrandGhost
Equality in C#: Reference, Value, and Operator Semantics
All articles

Equality in C#: Reference, Value, and Operator Semantics

Understand equality in C# through static operand types, reference identity, Equals, strings, records, structs, and boxed values with practical examples.

Equality in C# is not one universal operation. The equality operator, Equals methods, and ReferenceEquals answer related questions under different contracts. The compile-time operand types can determine which operator is selected even when the runtime objects appear unchanged.

Two independently created strings can have the same contents. Two records can represent the same value without sharing identity. Two boxes can hold equal integers while remaining separate objects. An ordinary struct can have Equals behavior without supporting the equality operator at all.

This article explains those distinctions rather than implementing a complete custom equality system. It does not repeat string culture policy, collection selection, hashing implementation, or ordered deduplication. The baseline is .NET 10 with C# 14, as documented in Microsoft's released C# 14 overview. The console examples retain older-compatible syntax. Record classes arrived in C# 9 and record structs in C# 10; those minimum syntax versions are not recommendations to use older runtimes. The results are documentation-derived, not executed tests or benchmarks.

Equality in C# Begins with the Question

Reference identity asks whether two references designate the same object. Value equality asks whether two values are equal under a relevant value contract. Operator equality asks what the selected equality operator defines for its operands.

These questions can produce different answers without any contradiction. Two separately created records may be value-equal but not reference-identical. A class may override Equals without also overloading ==, leaving its ordinary operator comparison as reference equality.

Microsoft's equality operator reference documents several type-specific rules. Reading only a summary that contrasts reference and value types is insufficient because strings, records, and user-defined operators introduce important qualifications.

Before explaining equality in C#, say which operation is used and which types participate. "The values look the same" is not a contract. "They refer to the same object" is a contract, but only for the identity question.

This distinction matters at API boundaries. A caller can receive a value through object, an interface, a base class, or a concrete type. Those declarations affect the operators and method overloads available at the call site.

Static Operand Types Select Operator Behavior

String equality is a clear example. When both operands are statically strings, == uses string equality, which compares contents with ordinal, case-sensitive semantics. When both operands are statically object, ordinary == compares object references.

using System;

string first = new string(new[] { 'c', 'o', 'd', 'e' });
string second = new string(new[] { 'c', 'o', 'd', 'e' });

object firstObject = first;
object secondObject = second;

Console.WriteLine(first == second);
Console.WriteLine(firstObject == secondObject);
Console.WriteLine(object.Equals(firstObject, secondObject));
Console.WriteLine(object.ReferenceEquals(firstObject, secondObject));

The documented results are true, false, true, false. The strings have equal contents but were separately created. Changing the variables through which they are compared changes the selected operator, not the string objects themselves.

The example deliberately avoids relying on string-literal interning. Two equal literals might share a reference, which could make an identity comparison accidentally look like a content comparison. Constructing two string instances makes the distinction visible.

Equality in C# therefore cannot be inferred solely from runtime type names. Operator overload resolution normally uses compile-time operand information. Dynamic binding is a separate surface and is not used in these examples.

C# 14 adds extension operators, but their lookup is considered only when no applicable predefined or non-extension user-defined operator exists. An imported extension therefore cannot override the applicable string or object equality used here. Extension equality declarations must provide the required ==/!= pair, and extension operators do not include implicit or explicit conversions.

The string comparison guide owns case, culture, and comparison API selection. Here the narrower question is dispatch: which operation the expression actually requests before any policy choice about text.

Equals Is a Method Contract

Equals can participate in virtual dispatch and overload resolution. A call through object can reach an overridden Equals method on the runtime type, while a type-specific Equals overload may be selected when the compile-time information permits it.

That is different from treating == as a virtual call automatically dispatched to an object's runtime equality operator. Ordinary operator selection is not replaced by virtual dispatch simply because the objects have a more specific runtime type.

The static object.Equals helper also has a useful null-handling contract. If both references are null, it returns true. If exactly one is null, it returns false. If both arguments refer to the same non-null object, it returns true without calling an override. For distinct non-null references, it invokes the first argument's Equals method. Calling an instance method on a null receiver instead fails.

Equality in C# still depends on implementation quality. A custom Equals override can define value equality well, poorly, or inconsistently with a separately overloaded operator. The language does not infer the entire equality design from an appealing method name.

This article does not implement those methods because that would change the reader job into an equality-design tutorial. The reading skill is to inspect the available contract and avoid assuming method equality and operator equality are necessarily interchangeable.

ReferenceEquals Answers Identity

ReferenceEquals is the explicit object-identity operation. For reference-type objects, it reports whether its two arguments refer to the same instance. It is not redirected to an overloaded equality operator.

Both null arguments are considered the same reference. One null argument and one non-null argument are not. These cases make identity useful when the question really concerns aliasing or instance reuse.

ReferenceEquals does not compare the contents of ordinary values. Passing value types to its object parameters can box them. Separate boxes are separate objects, even when they contain equal values.

For equality in C#, this supplies an important boundary: identity can be a meaningful question about references, but it is not a shortcut for value equality. Calling it on numbers does not ask whether the numeric values match.

Identity and domain identity are also different. Two objects can carry the same account identifier while being separate in-memory instances. An application's entity identity policy is not automatically the same as object reference identity.

The correct operation depends on which question the caller needs answered. ReferenceEquals is explicit about one question; it does not make that question universally appropriate.

Ordinary Classes and Records Differ

An ordinary class without an overloaded equality operator uses reference equality for ==. A record class receives generated value-oriented equality behavior, including equality operators, subject to its components and record type rules.

using System;

var firstClass = new Ticket(42);
var secondClass = new Ticket(42);
Console.WriteLine(firstClass == secondClass);

var firstRecord = new TicketValue(42);
var secondRecord = new TicketValue(42);
Console.WriteLine(firstRecord == secondRecord);
Console.WriteLine(object.ReferenceEquals(firstRecord, secondRecord));

object firstAsObject = firstRecord;
object secondAsObject = secondRecord;
Console.WriteLine(firstAsObject == secondAsObject);
Console.WriteLine(object.Equals(firstAsObject, secondAsObject));

public sealed class Ticket
{
    public Ticket(int number)
    {
        Number = number;
    }

    public int Number { get; }
}

public sealed record TicketValue(int Number);

The ordinary class comparison is false. The record comparison is true because the records have equal components under the generated contract. ReferenceEquals remains false. The object-typed == comparison is false, while object.Equals reaches record value equality and returns true.

Equality in C# does not stop being type-sensitive when a record is involved. The generated record operator is available through record-typed operands; declaring the operands as object requests a different operator.

Microsoft's record documentation explains generated value equality. It also distinguishes record classes, which are reference types, from record structs, which are value types.

The example's immutable integer component makes its value contract simple. It does not establish that every record graph compares recursively by contents or that every record property is deeply immutable.

Record Equality Is Not Automatic Deep Comparison

Generated record equality uses the equality behavior of the record's components. If a component is an array, list, or another reference whose ordinary equality is identity-based, two separately created but similar containers may not compare equal.

This is a crucial qualification to "records have value equality." The record compares according to component contracts. It does not recursively invent a content comparison policy for every object reachable from those components.

A record can also contain a mutable reference. An init-only property prevents reassignment through that property after initialization, but it does not freeze the object the property references. A shallow copy can continue sharing that referenced object.

Equality in C# needs the same component inspection here as anywhere else. Ask what each compared member considers equal and whether that state can change. Generated code reduces implementation work, not the need to understand the model.

The published custom multi-type conversion article illustrates a different distinction: a wrapper's branch and payload have their own meanings. Converting or wrapping a value does not automatically establish a new universal equality contract for every payload.

This article deliberately stops short of collection comparer implementation. Comparing record components and selecting collection membership policy are connected topics, but they are not the same reader task.

Ordinary Structs Do Not Receive == Automatically

An ordinary user-defined struct is a value type, but it does not support == by default. It can use Equals behavior inherited from ValueType or an implementation supplied by the author. Operator availability is a separate matter.

Record structs do receive generated equality operators. Their introduction in C# 10 sets the minimum syntax version for this example; the article's current baseline remains .NET 10/C# 14.

using System;

var first = new Coordinate(2, 3);
var second = new Coordinate(2, 3);
Console.WriteLine(first.Equals(second));

var firstRecord = new CoordinateValue(2, 3);
var secondRecord = new CoordinateValue(2, 3);
Console.WriteLine(firstRecord == secondRecord);

object firstBox = 42;
object secondBox = 42;
Console.WriteLine(firstBox == secondBox);
Console.WriteLine(object.Equals(firstBox, secondBox));
Console.WriteLine(object.ReferenceEquals(firstBox, secondBox));

public readonly struct Coordinate
{
    public Coordinate(int x, int y)
    {
        X = x;
        Y = y;
    }

    public int X { get; }

    public int Y { get; }
}

public readonly record struct CoordinateValue(int X, int Y);

The ordinary struct's Equals comparison is true for these equal integer fields. The record struct's generated operator comparison is true. The two boxed integers compare false by object-typed ==, true by object.Equals, and false by ReferenceEquals.

The example intentionally does not include a non-compiling ordinary-struct == expression in the runnable block. Its absence is part of the lesson: Equals is available, while == would require an appropriate declared operator.

Equality in C# should therefore never be summarized as "all value types compare contents with ==." Built-in numeric types and record structs have suitable operators, but ordinary struct declarations need their own operator surface.

Boxing Preserves Value but Creates an Object Boundary

Boxing copies a value-type value into an object representation. Two separate boxing operations can produce separate objects holding equal values. Comparing those object references does not compare the contained values.

The boxed integer example distinguishes those questions without relying on a custom type. Object-typed == asks about references. Object.Equals reaches the boxed value's equality behavior. ReferenceEquals makes the identity question explicit.

The runtime type of a boxed value also matters to Equals. A boxed int and a boxed long are not made equal merely because their displayed numbers match. Numeric promotion applicable to an ordinary numeric operator expression is not automatically applied by object.Equals.

This is another reason to separate conversion from equality. The automatic casting guide explains conversions, while equality in C# explains what contract is requested after values arrive at a comparison boundary.

No allocation or performance percentage is inferred from this example. Boxing has a semantic effect worth understanding without turning a documented behavior demonstration into an unmeasured optimization claim.

Null Checks Should State Their Intent

A null pattern checks whether a reference is null without invoking an overloaded equality operator. That can be useful when the purpose is to establish whether an object exists rather than consult a custom operator's equality policy.

An overloaded == may have its own null behavior. Well-designed implementations generally handle null coherently, but the compiler does not prove the author's entire equality policy is sensible.

Object.Equals offers its documented null-safe helper behavior. An instance Equals call requires a non-null receiver. ReferenceEquals can also compare references against null when identity is the intended question.

Equality in C# is easier to explain when these surfaces are named rather than treated as stylistic alternatives. A null pattern, a helper method, and an overloaded operator can serve different intentions even when a small example gives the same answer.

The implicit-operator tradeoff discussion is relevant to this reading habit: convenience syntax does not remove the need to inspect user-written behavior. This article does not prescribe a custom equality implementation.

Choose the Contract Before Comparing Values

Begin by deciding whether the requirement concerns the same instance, the same value, or the same domain entity. Then inspect the available operator or method contract and the compile-time operand types at the comparison.

There are tradeoffs. Operators are concise and familiar but can conceal type-sensitive dispatch. Explicit helper or method calls can make the question more visible but still require knowledge of overloads and implementation. Neither surface is universally safer merely because it is longer or shorter.

Use inputs that separate contracts. Two equal literals can conceal reference sharing. Two references to one record cannot distinguish value equality from identity. Two separately constructed equal values provide a stronger explanatory example.

Finally, keep collection policy separate. A comparer supplied to a collection can establish its own equality rule; it does not rewrite every standalone == expression elsewhere. Equality in C# is local to the operation and contract actually requested.

Frequently Asked Questions

These comparison surfaces answer distinct questions under their applicable contracts.

Surface Contract to inspect
== Operator selected from operand types
Equals Selected method and runtime implementation
ReferenceEquals Object identity
Generated record equality Record type and component equality

Is == the same as Equals?

Not universally. The selected operator and the selected method can have different contracts and dispatch behavior. Strings and records provide value-oriented operators, while ordinary object-typed == compares references. An override of Equals does not automatically create an equality operator.

Why do equal strings compare differently through object variables?

String-typed operands select string content equality. Object-typed operands ordinarily select reference equality. The objects can remain unchanged while the compile-time declarations change the selected operator. Separately created strings expose the difference without relying on literal interning.

What does ReferenceEquals check?

It checks object reference identity, including the case of both arguments being null. It does not invoke an overloaded equality operator or compare ordinary value-type contents. Value-type arguments may be boxed to satisfy its object parameters.

Do ordinary structs support == by default?

No. They have Equals behavior but need an appropriate equality operator declaration to use ==. Record structs receive generated operators. Being a value type is not itself a promise that the language supplies every comparison syntax.

Does record equality compare nested collections by contents?

Not automatically. Generated equality uses the components' equality contracts. A referenced array or collection may use identity rather than sequence contents. Value-oriented record equality does not invent deep comparison semantics for every nested object.

Are separately boxed equal integers the same object?

No. Separate boxes can contain equal integers while having distinct reference identities. Object.Equals can report equal contained values, while object-typed == and ReferenceEquals report different references. The operation being requested determines which answer is relevant.

Equality in C# Needs an Explicit Contract

Equality in C# becomes clearer when operator selection, method dispatch, value equality, and reference identity are kept separate. Static types matter. Generated record behavior has boundaries. Ordinary structs and boxed values expose why broad reference-versus-value slogans are incomplete.

Ask the comparison question first, then read the contract that answers it. That approach explains differences without inventing a universal equality rule or duplicating the separate policies for text and collection membership.

Get the Dev Leader Newsletter

C#, .NET, and software engineering content every Saturday.

Subscribe to the newsletter
An error has occurred. This application may no longer respond until reloaded. Reload