Back to Blog
C#

C# HashSet Contains: Fast Lookup and Usage

c# hashset contains: Learn how HashSet.Contains works, its O(1) average lookup, custom type support, and when to choose HashSet over List for membership tests.

HashSetC# CollectionsContains MethodPerformanceSet Operations
Illustration of a HashSet with a magnifying glass over an item, representing fast Contains lookup.

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

When you need to test whether an item already exists in a collection, HashSet.Contains is often the first choice because it performs the lookup in constant average time. Unlike List.Contains, which scans every element until a match is found, a HashSet uses a hash table internally, so the cost of a membership test does not grow linearly with the number of stored items.

How HashSet.Contains Works

A HashSet<T> stores elements in buckets based on their hash code. When you call Contains, the runtime computes the hash code of the argument, locates the corresponding bucket, and then checks for equality with the elements in that bucket. This is why the average time complexity is O(1), assuming a good hash function and adequate bucket distribution.

The default equality comparer for HashSet<T> is EqualityComparer<T>.Default. For primitive types like int or string, this uses the type's GetHashCode and Equals methods. For custom classes, it uses object.GetHashCode and object.Equals unless you override them or supply a custom IEqualityComparer<T> to the HashSet constructor.

var ids = new HashSet<int> { 1, 2, 3 }; bool exists = ids.Contains(2); // true bool missing = ids.Contains(5); // false

The above code is straightforward, but the real power of HashSet.Contains emerges when you work with larger collections or need to perform many membership tests in a loop.

Using Contains with Custom Types

For custom types, HashSet.Contains relies on your implementation of GetHashCode and Equals. If you do not override these methods, the default reference equality is used, which often leads to unexpected results when you create two objects with identical field values.

public class Product { public int Id { get; set; } public string Name { get; set; } public override bool Equals(object obj) { return obj is Product other && Id == other.Id && Name == other.Name; } public override int GetHashCode() { return HashCode.Combine(Id, Name); } }

With these overrides, two Product instances with the same Id and Name are considered equal, and Contains will return true when such an instance exists in the set. Without them, each new Product is a distinct reference, and Contains would only find the exact same object you inserted.

If you cannot modify the class, you can pass a custom IEqualityComparer<T> to the HashSet constructor. This is useful when the equality logic is context-specific or when you want to compare only a subset of properties.

var comparer = Comparer<Product>.Create((a, b) => a.Id == b.Id); var set = new HashSet<Product>(comparer);

Note that Comparer<T>.Create is not the right API here; you need an IEqualityComparer<T>. A correct implementation would be a separate class or a lambda-based comparer if you use a helper library. For clarity, define a comparer explicitly:

public class ProductIdComparer : IEqualityComparer<Product> { public bool Equals(Product x, Product y) => x.Id == y.Id; public int GetHashCode(Product obj) => obj.Id.GetHashCode(); }

Then use it: new HashSet<Product>(new ProductIdComparer()). This allows Contains to match products by Id alone, ignoring the Name property.

Performance Characteristics

The primary reason to use HashSet.Contains is performance. For a List<T>, Contains is O(n) because it must iterate through the entire list in the worst case. For a HashSet<T>, the average case is O(1), but the actual cost depends on the quality of the hash function and the number of collisions.

CollectionContains ComplexityMemory Overhead
List<T>O(n)Low
HashSet<T>O(1) averageHigher (buckets)

In practice, if you are performing many membership tests—for example, in a loop that processes thousands of items—the difference is substantial. A HashSet trades memory for speed. The internal storage uses more memory than a list because it maintains an array of buckets and stores the elements in a way that allows fast lookup.

There is also a one-time cost of building the HashSet. If you only need to check a few items and the collection is small, a List may be sufficient and more memory-efficient. The decision should be based on the number of lookups relative to the collection size.

When to Use HashSet.Contains vs List.Contains

Choose HashSet.Contains when:

  • You need to test membership frequently (e.g., in a loop or for each incoming request).
  • The collection size is large enough that linear scans become noticeable.
  • Duplicate elements are not needed; a set naturally enforces uniqueness.

Choose List.Contains when:

  • The collection is very small (e.g., fewer than 10 items) and lookups are rare.
  • You need to preserve order and allow duplicates.
  • The memory overhead of a hash table is a concern in a memory-constrained environment.

For a one-off check, the difference is negligible. But if you are building a cache or a validation set that will be queried repeatedly, HashSet is the correct choice.

Common Pitfalls and Edge Cases

One common mistake is using a mutable object as a key in a HashSet. If you modify an object after adding it to the set, its hash code changes, and Contains will no longer find it because the bucket is determined by the hash code at insertion time. This can lead to subtle bugs.

var product = new Product { Id = 1, Name = "Laptop" }; var set = new HashSet<Product> { product }; product.Name = "Desktop"; // hash code changes if Name is part of it bool found = set.Contains(product); // may be false

To avoid this, treat objects stored in a HashSet as immutable, or use a hash code that does not change over the object's lifetime (e.g., based on a stable ID).

Another edge case is null. HashSet<T> allows null for reference types, and Contains(null) works correctly. However, if you use a custom comparer that does not handle null, you may get a NullReferenceException. Always ensure your comparer's Equals and GetHashCode methods handle null arguments gracefully.

Thread Safety and Concurrent Scenarios

HashSet<T> is not thread-safe for concurrent reads and writes. If multiple threads call Contains while another thread modifies the set, you may get inconsistent results or exceptions. The .NET documentation states that a HashSet can support multiple concurrent readers as long as the set is not modified. For read-heavy scenarios with occasional writes, you can use a lock or ReaderWriterLockSlim to protect access.

For high-concurrency scenarios where you need both frequent reads and writes, consider ConcurrentDictionary<T, byte> or ConcurrentDictionary<T, T> as a set-like structure. ConcurrentDictionary provides thread-safe ContainsKey and TryAdd methods, and its performance is acceptable for many workloads.

var set = new ConcurrentDictionary<string, byte>(); set.TryAdd("key", 0); bool exists = set.ContainsKey("key");

This approach avoids the need for explicit locking and is safer for concurrent access. However, it uses more memory because each entry is a key-value pair.

Alternative Lookup Structures

If you need to store not just membership but also associated data, a Dictionary<TKey, TValue> is a natural choice. The ContainsKey method behaves similarly to HashSet.Contains in terms of performance, but it also lets you retrieve the value. If you only need a set, HashSet is more memory-efficient because it does not store values.

For sorted lookups, SortedSet<T> provides O(log n) performance and keeps elements in order. Use it when you need to iterate in sorted order or perform range queries. The tradeoff is slower lookup than HashSet but better than List for large collections.

When you need to test membership against a small, fixed set of values, a simple if statement or a switch expression may be clearer and faster than any collection. Avoid over-engineering; use a HashSet when the number of items is dynamic or the lookup is repeated many times.

c# hashset contains: Practical Usage and Code Examples | RYUSLOG DEV