DEV Community

Cover image for C# records, structs and equality: what == really compares
Naze Code
Naze Code

Posted on

C# records, structs and equality: what == really compares

== looks like the simplest operator in C#. But what it compares depends on what you're comparing, and records, which were supposed to make equality "just work", have a couple of edges that bite in real code.

Four small experiments, each run for real on .NET 10 (Release build).

1. Class vs record

public class UserClass(string name, string email)
{
    public string Name { get; } = name;
    public string Email { get; } = email;
}

public record UserRecord(string Name, string Email);
Enter fullscreen mode Exit fullscreen mode
var a = new UserClass("Ada", "ada@x.dev");
var b = new UserClass("Ada", "ada@x.dev");
Console.WriteLine($"class:  {a == b}");

var c = new UserRecord("Ada", "ada@x.dev");
var d = new UserRecord("Ada", "ada@x.dev");
Console.WriteLine($"record: {c == d}");
Enter fullscreen mode Exit fullscreen mode
class:  False
record: True
Enter fullscreen mode Exit fullscreen mode

For a class, == asks "is this the same object?" Two separate objects with identical data are not equal.

For a record, the compiler generates Equals, GetHashCode, == and != for you, and they compare each field. Same data, equal records. That's the main reason to use a record for data like DTOs, messages and value objects.

2. A record holding a list

public record Order(string Customer, List<string> Items);

var first  = new Order("Ada", ["laptop", "mouse"]);
var second = new Order("Ada", ["laptop", "mouse"]);

Console.WriteLine($"Equal? {first == second}");
Enter fullscreen mode Exit fullscreen mode
Equal? False
Items equal? False · same contents? True
Enter fullscreen mode Exit fullscreen mode

Same customer and same items, but not equal. Record equality compares each field with that field type's own equality. string compares by value, but List<string> is a class, so its equality is reference equality. Two different lists with the same contents aren't "equal" to it.

Takeaway: a record is only value-equal "all the way down" if its fields are. For collections, either compare them yourself (SequenceEqual), or override Equals in the record. Don't assume it.

3. with makes a shallow copy

var original = new Order("Ada", ["laptop"]);
var copy = original with { Customer = "Bob" };

copy.Items.Add("headphones");

Console.WriteLine($"Bob's items: {string.Join(", ", copy.Items)}");
Console.WriteLine($"Ada's items: {string.Join(", ", original.Items)}");
Enter fullscreen mode Exit fullscreen mode
Bob's items: laptop, headphones
Ada's items: laptop, headphones
Enter fullscreen mode Exit fullscreen mode

with copies each field's value. For a list, the value is a reference, so Bob's order and Ada's order now share the same list. Adding to one adds to both.

Takeaway: if a record holds something mutable, with doesn't protect you. Give the copy its own new list when you use with, or use immutable collections in records.

4. The struct whose Equals allocates

Two structs with the same fields, one a plain struct, one a record struct:

public struct Point
{
    public string Label;
    public int X, Y;
}

public record struct PointRecord(string Label, int X, int Y);
Enter fullscreen mode Exit fullscreen mode

Search a list of 2,000 of each, 1,000 times, for an item that isn't there:

var timer = Stopwatch.StartNew();
for (var i = 0; i < 1_000; i++)
    points.Contains(new Point { Label = "p", X = -1, Y = -1 });
Console.WriteLine($"struct:        {timer.ElapsedMilliseconds} ms");

timer.Restart();
for (var i = 0; i < 1_000; i++)
    records.Contains(new PointRecord("p", -1, -1));
Console.WriteLine($"record struct: {timer.ElapsedMilliseconds} ms");
Enter fullscreen mode Exit fullscreen mode
struct:        244 ms
record struct: 16 ms
allocated per Contains over 2,000 items: struct 320,000 B · record struct 0 B
Enter fullscreen mode Exit fullscreen mode

About 15 times slower, and 320 KB of garbage for a single Contains call (160 bytes per comparison).

A plain struct that doesn't override Equals gets ValueType.Equals. When the struct contains a reference-type field (the string here), that default implementation falls back to reflection to compare the fields, boxing as it goes. A record struct gets a generated, field-by-field Equals instead, with no reflection and no allocations.

Takeaway: if you put structs in lists, dictionaries or hash sets, make them record structs, or implement IEquatable<T> yourself.

Recap

  1. Class == compares references. Record == compares fields.
  2. Record equality is only as deep as its fields' equality; lists compare by reference.
  3. with copies references, so mutable fields end up shared.
  4. A plain struct's default Equals can use reflection and allocate. Use record struct or IEquatable<T>.

Have you been bitten by record equality on a collection? Tell me in the comments.

I make short, tested videos about C# and what's really going on under the hood. More on the Naze Code YouTube channel.


Sources: Microsoft Learn, Records, ValueType.Equals (remarks on reflection), with expression. Tested on .NET SDK 10.0.302, Release build. Timings are from one run; the research runs ranged 229–258 ms vs 16–17 ms.

Top comments (0)