Back to Blog
C#

Using LINQ Except in C# to Find Set Differences

c# linq except: Learn how LINQ Except works in C#: default equality, custom comparers, duplicate handling, order preservation, and performance tradeoffs.

LINQC#Set OperationsIEnumerableEquality Comparer.NET
Illustration of two overlapping sets with the exclusive region of the first set highlighted, representing the C# LINQ Except set difference operation.

c# linq except requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The C# LINQ Except operator returns the set difference between two sequences: every element from the first sequence that does not appear in the second. It belongs to the LINQ set operators, alongside Distinct, Intersect, and Union.

int[] first = { 1, 2, 3, 4, 5 }; int[] second = { 2, 4, 6 }; IEnumerable<int> difference = first.Except(second); foreach (int value in difference) { Console.WriteLine(value); }

The output is 1, 3, 5. The elements 2 and 4 are removed because they exist in second, and 6 never appears in first, so it has no effect on the result.

Except has two overloads: one that uses the default equality comparer for the element type, and one that accepts an IEqualityComparer<T> instance. The second overload is what you reach for when the default comparison does not match the semantics you need.

How the Default Equality Comparison Works

Except relies on EqualityComparer<T>.Default unless you pass a custom comparer. What that means depends on the element type.

For value types such as int, decimal, or DateTime, comparison is value-based, so two instances with the same value are treated as equal. For string, comparison is ordinal and case-sensitive by default. For reference types that do not override Equals and GetHashCode, comparison is reference-based: two objects are equal only if they are the same instance.

This default behavior is a common source of surprises. Consider a Person class with Id and Name properties. Two Person instances with the same data are different objects, so Except will not remove them unless the class overrides equality semantics.

public class Person { public int Id { get; set; } public string Name { get; set; } = string.Empty; } var teamA = new List<Person> { new() { Id = 1, Name = "Alice" }, new() { Id = 2, Name = "Bob" } }; var teamB = new List<Person> { new() { Id = 1, Name = "Alice" } }; var result = teamA.Except(teamB).ToList(); // Both Person instances remain, because reference equality is used.

Records are the exception to this rule. A record overrides Equals and GetHashCode based on its property values, so Except behaves value-based for records without extra code. For regular classes, you either override Equals and GetHashCode or supply a comparer.

Using a Custom Equality Comparer

When the default comparison is wrong for your data, implement IEqualityComparer<T> and pass it to Except. The comparer must be consistent: if Equals returns true for two objects, GetHashCode must return the same hash code for both.

A common scenario is case-insensitive string comparison:

var allowed = new[] { "admin", "editor", "viewer" }; var current = new[] { "Admin", "Editor", "Guest" }; var missing = current.Except(allowed, StringComparer.OrdinalIgnoreCase); // Returns "Guest"

StringComparer.OrdinalIgnoreCase is a ready-made IEqualityComparer<string> that handles the hash code contract correctly. For custom types, write the comparer explicitly:

public class PersonComparer : IEqualityComparer<Person> { public bool Equals(Person? x, Person? y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return x.Id == y.Id; } public int GetHashCode(Person obj) => obj.Id; }

The hash code must come from the same fields used in Equals. If Equals compares Id but GetHashCode hashes Name, two people with the same Id but different Name values will land in different buckets, and Except will incorrectly treat them as different.

What Happens to Duplicates and Order

Except is a set operation, and the result is a set: it contains distinct elements only. If the first sequence contains duplicates, only the first occurrence of each distinct value is yielded.

int[] first = { 1, 1, 2, 3, 3, 4 }; int[] second = { 2 }; var result = first.Except(second).ToList(); // Result: 1, 3, 4

The duplicate 1 values collapse into a single 1, and the duplicate 3 values collapse into a single 3. If you need to preserve every occurrence from the first sequence, Except is not the right tool; you would need a manual filter that tracks the excluded set without deduplicating the output.

Order is preserved from the first sequence. The elements that survive appear in the same relative order they had in first. The order of second is irrelevant because it is only used to build the exclusion set.

Performance and Memory Behavior

Except builds a hash set from the second sequence, then iterates the first sequence and checks membership in that set. The average time complexity is O(n + m), where n is the size of the first sequence and m is the size of the second. Memory usage is O(m) because the second sequence is materialized into the set.

This has two practical implications. First, the second sequence is fully enumerated when the result is first iterated, not when Except is called. The method uses deferred execution, so nothing happens until you call foreach, ToList, ToArray, or another consuming operation.

Second, the first sequence is streamed lazily. If the first sequence is a large database-backed query or a generator, elements are pulled one at a time. The second sequence, however, must fit in memory as a set. For very large second sequences, that allocation can be significant.

When the second sequence is small, the set is small and the overhead is minimal. When both sequences are large, the hash-based approach is still far cheaper than a naive nested-loop comparison, which would be O(n × m).

Common Mistakes and Misconceptions

One recurring mistake is expecting Except to preserve duplicates from the first sequence. As shown above, it does not. Another is assuming the default comparer handles custom classes value-based, which it does not unless the class is a record or overrides equality members.

A third mistake is writing a comparer whose GetHashCode is inconsistent with Equals. The hash code is used to place elements into buckets; if two equal elements produce different hash codes, Except may miss the match entirely.

A fourth issue is using Except with a comparer that mutates state. IEqualityComparer<T> instances should be stateless and thread-safe. A comparer that caches results or depends on mutable fields can produce nondeterministic results, especially when the same comparer is shared across concurrent queries.

Choosing Between Except and Other Set Operations

Except is one of four set operators in LINQ, and each answers a different question:

OperatorReturns
ExceptElements in first but not in second
IntersectElements present in both sequences
UnionDistinct elements from both sequences
DistinctDistinct elements from a single sequence

Distinct is effectively Except with an empty second sequence. If you need to remove elements based on a key rather than the whole object, .NET 6 introduced ExceptBy, which takes a key selector and a key comparer:

var users = new[] { new { Id = 1, Name = "Alice" }, new { Id = 2, Name = "Bob" }, new { Id = 3, Name = "Carol" } }; var blockedIds = new[] { 2 }; var activeUsers = users.ExceptBy(blockedIds, user => user.Id); // Returns Alice and Carol

ExceptBy is useful when the comparison key is a single property or a computed value. It avoids writing a full IEqualityComparer<T> when the key is simple, and it keeps the key extraction logic in one place.

Use Except when you need set difference over whole elements and the default or a custom comparer expresses the equality rule cleanly. Use ExceptBy when the identity of an element is a single key. Use a manual filter when you must preserve duplicates from the first sequence, because no LINQ set operator will do that for you.

c# linq except: Practical Usage and Code Examples | RYUSLOG DEV