Distinct() appears not to work on a list of objects because it is working exactly as defined: it removes elements that are equal, and two instances of a class are equal only if they are the same instance. Two Customer objects with identical fields are not equal, so both survive. The fix is to tell LINQ what equality means for your type, and there are three ways to do it.
What is happening#
class Customer
{
public string Email { get; set; } = "";
public string Name { get; set; } = "";
}
var list = new List<Customer>
{
new() { Email = "a@example.com", Name = "Ann" },
new() { Email = "a@example.com", Name = "Ann" },
};
list.Distinct().Count(); // 2 - both kept
Distinct uses EqualityComparer<T>.Default, which calls Equals and GetHashCode. A class inherits both from object, where they mean “same reference”. Value types and strings compare by value, which is why Distinct on a List<int> or List<string> works and on a List<Customer> does not.
Fix 1: DistinctBy#
var unique = list.DistinctBy(c => c.Email);
Available since .NET 6. You name the key that defines “the same”, and the first element with each key is kept. For a composite key, use a tuple:
var unique = list.DistinctBy(c => (c.Email, c.Name));
This is the right answer when the notion of duplicate is specific to this query rather than intrinsic to the type. It changes nothing about the class.
Fix 2: make it a record#
record Customer(string Email, string Name);
list.Distinct().Count(); // 1
Records get value-based Equals and GetHashCode generated over all their properties. If your type is a data holder that should compare by content everywhere — DTOs, keys, values — a record is the correct declaration, and Distinct, Contains, GroupBy, dictionary keys and HashSet all start behaving.
The same applies to record struct for small value types.
Fix 3: implement IEquatable#
When the type must stay a class and equality should be by some of its fields:
class Customer : IEquatable<Customer>
{
public string Email { get; set; } = "";
public string Name { get; set; } = "";
public bool Equals(Customer? other) =>
other is not null &&
string.Equals(Email, other.Email, StringComparison.OrdinalIgnoreCase);
public override bool Equals(object? obj) => Equals(obj as Customer);
public override int GetHashCode() =>
StringComparer.OrdinalIgnoreCase.GetHashCode(Email);
}
All three members are needed. IEquatable<T> is what LINQ prefers; Equals(object) keeps non-generic code consistent; and GetHashCode is what makes Distinct fast — and correct.
Fix 4: a comparer, for one call#
class CustomerByEmail : IEqualityComparer<Customer>
{
public bool Equals(Customer? a, Customer? b) => a?.Email == b?.Email;
public int GetHashCode(Customer c) => c.Email.GetHashCode();
}
list.Distinct(new CustomerByEmail());
Useful when the type is not yours and the rule is reused in several places. For a single query, DistinctBy is shorter.
Mutable objects and hash codes#
If equality depends on a property and that property changes after the object is in a HashSet or used as a dictionary key, the object is filed under its old hash and becomes unfindable. Either base equality on immutable fields, or make the type immutable — which is what records encourage.
Distinct and order#
Distinct preserves the order of first occurrence, though the documentation does not promise it. If order matters, DistinctBy after an OrderBy makes the intent explicit: sort so that the element you want to keep comes first.
Questions people ask#
Why does Distinct work on strings?
Because string overrides Equals and GetHashCode to compare contents. Your class inherits the reference-based defaults from object.
Does GroupBy have the same problem?
Yes, and the same fixes. GroupBy(c => c.Email) groups by a string key, which is why it usually works when Distinct on the object does not.
Is DistinctBy available in older .NET?
From .NET 6. On older frameworks, GroupBy(key).Select(g => g.First()) does the same job.
Should every class implement IEquatable?
No. Only types with a meaningful value identity. For most entity classes, reference equality is correct and a record or DTO handles the value cases.
Where to go next#
- C# collections — HashSet and Dictionary rely on the same rules.
- C# basics — classes, structs and records.
- Java records vs classes — the same idea in Java, if you work across both.