C# LINQ Contains: Usage, Performance, and Pitfalls
c# linq contains: Understand C# LINQ Contains: how equality is determined, when custom comparers are needed, and how performance differs between List and HashSet.
When you use c# linq contains in your code, you are calling the Enumerable.Contains extension method, which returns true if a sequence contains a specific element and false otherwise. The method performs a linear scan over the sequence and stops as soon as it finds a match, so it never visits more elements than necessary. This short-circuit behavior is the first thing to understand about the method, because it determines both the runtime cost and the semantics of every call.
Basic Usage Across Collection Types
The simplest form of Contains takes a single value and compares it against each element using the default equality comparer for the element type.
List<string> names = new() { "ada", "grace", "alan" }; bool hasGrace = names.Contains("grace"); // true bool hasLinus = names.Contains("linus"); // false
Because Contains is an extension method on IEnumerable<TSource>, it works on any type that implements that interface: arrays, List<T>, HashSet<T>, and lazy sequences produced by other LINQ operators.
int[] ids = { 10, 20, 30, 40 }; bool found = ids.Contains(25); // false
For value types like int and decimal, the default comparer compares by value. For string, it also compares by value because string overrides Equals. For most other reference types, the default comparer uses reference equality unless the type overrides Equals and GetHashCode.
How Equality Is Determined
The behavior of Contains depends entirely on the equality comparer it uses. By default, that is EqualityComparer<TSource>.Default, which delegates to the type's own Equals and GetHashCode implementation.
Consider a regular class:
public class Product { public int Id { get; set; } public string Name { get; set; } }
List<Product> products = new() { new() { Id = 1, Name = "Keyboard" }, new() { Id = 2, Name = "Mouse" } }; bool exists = products.Contains(new() { Id = 1, Name = "Keyboard" }); // false
This returns false because the two Product instances are different references and the class does not override Equals. The record type solves this cleanly:
public record Product(int Id, string Name); List<Product> products = new() { new(1, "Keyboard"), new(2, "Mouse") }; bool exists = products.Contains(new(1, "Keyboard")); // true
Records implement value equality automatically, so two instances with the same property values compare equal.
Custom Comparers for Non-Value Types
When you cannot change the type itself, pass an IEqualityComparer<TSource> to the second overload of Contains. This keeps the equality rule local to the call site.
class ProductByIdComparer : IEqualityComparer<Product> { public bool Equals(Product? x, Product? y) => x is not null && y is not null && x.Id == y.Id; public int GetHashCode(Product obj) => obj.Id; } bool exists = products.Contains(new Product(1, "Keyboard"), new ProductByIdComparer()); // true
The comparer's GetHashCode must be consistent with its Equals method. If two objects compare equal, they must produce the same hash code. Violating this rule leads to unpredictable results, especially when the comparer is used inside hash-based collections.
String.Contains vs the LINQ Extension
The string type has its own instance method named Contains, which is distinct from the LINQ extension method. The instance method checks whether a substring exists within the string:
string message = "connection refused"; bool hasConnection = message.Contains("connection"); // true
The LINQ Enumerable.Contains extension method, when applied to a string, treats the string as a sequence of characters:
string message = "connection"; bool hasLetterC = message.Contains('c'); // true
The compiler resolves the instance method first, so message.Contains("connection") calls string.Contains, not the LINQ version. To force the LINQ version, call it explicitly:
bool hasLetterX = Enumerable.Contains(message, 'x'); // false
Both methods are case-sensitive by default. Neither performs culture-aware matching unless you supply a comparer.
Performance: Linear Scan vs Hash Lookup
Enumerable.Contains iterates the sequence from the beginning and stops at the first match. In the worst case, where the element is absent or appears at the end, it visits every element. That makes the method O(n) for a sequence of n elements.
For a List<T> or an array, this linear scan is the only option. For a HashSet<T>, the type has its own Contains method that uses the hash code to locate the element in constant time, O(1). If you need to perform many membership checks against the same data, converting to a HashSet<T> once and reusing it is the standard approach.
List<int> ids = LoadIds(); // large list bool a = ids.Contains(1001); // O(n) each time bool b = ids.Contains(1002); // O(n) each time
HashSet<int> idSet = LoadIds().ToHashSet(); bool a = idSet.Contains(1001); // O(1) bool b = idSet.Contains(1002); // O(1)
The conversion itself is O(n), so the tradeoff pays off only when you perform more than a few lookups. For a single lookup against a small collection, the linear scan is simpler and avoids the allocation of a new set.
There is also a subtle behavior worth knowing: Contains on a lazy IEnumerable<T> (such as a Select or Where result) executes the pipeline until it finds a match. The short-circuit limits the work, but the pipeline still runs for every element up to the match.
Contains in Entity Framework Queries
When you use Contains inside an Entity Framework query, the behavior differs from in-memory LINQ. EF Core translates Contains on a collection of primitive values into a SQL IN clause.
var ids = new[] { 1, 2, 3, 4 }; var orders = await context.Orders .Where(o => ids.Contains(o.Id)) .ToListAsync();
This generates SQL similar to:
SELECT * FROM Orders WHERE Id IN (1, 2, 3, 4)
The translation works only when the collection is a simple list of values. If you call Contains with a custom comparer or on a collection of complex objects, EF Core may not translate it to SQL and can fall back to client-side evaluation or throw, depending on the version and configuration. For large IN lists, some databases impose parameter limits, so you may need to batch the query.
The key point is that Contains in an EF query is not evaluated in memory. It becomes part of the SQL statement, and the database engine decides how to execute it, which usually means an index seek or scan depending on whether the column is indexed.
Edge Cases That Cause Bugs
Null handling is a common source of confusion. Contains on a collection that contains null works correctly when the argument is null, because the default equality comparer treats null as equal to null. But if you pass a non-null value to a collection that contains null elements, the comparison simply fails for those elements and iteration continues.
List<string?> values = new() { "a", null, "c" }; bool hasNull = values.Contains(null); // true
Case sensitivity is another frequent surprise. Contains on a List<string> uses ordinal case-sensitive comparison. To perform a case-insensitive check, pass a comparer:
List<string> names = new() { "ADA", "Grace" }; bool found = names.Contains("ada", StringComparer.OrdinalIgnoreCase); // true
Finally, remember that Contains returns a bool, not the element itself. If you need the matching element, use FirstOrDefault or SingleOrDefault instead. Contains answers a yes-or-no question and nothing more.