Updated on

To filter a list based on another list in C#, we call Where() on the first list and pass it the second list’s Contains() method: listToFilter.Where(filteringList.Contains).ToList(). To drop the matches instead of keeping them, we negate the check.

Lists are one of the most common data structures in C#, and we append to them, sort them and filter them all the time. Here we’ll focus on filtering, and in particular on how to use a list to filter other lists. We can do it in several ways, with or without LINQ, and they don’t all treat duplicates the same way.

To download the source code for this article, you can visit our GitHub repository.

Let’s have a look at the most typical scenarios. 

How Do We Filter a List in C#?

Filtering a list generally means keeping some elements in the list, while removing others. We pick a condition, and every element either passes it or doesn’t.

In C#, the usual tool is the LINQ Where() method. It takes the condition as a lambda, returns the elements that pass it as an IEnumerable<T>, and a call to ToList() turns that into a new list.

List<T> also has two filtering methods of its own that take the same kind of condition. FindAll() returns a new List<T> straight away. RemoveAll() changes the original list in place and returns the number of elements it removed.

When the condition comes from a second list, it becomes a membership check. We keep the elements the second list contains, or the ones it doesn’t, by calling the second list’s Contains() method inside the condition.

That is enough for small lists. For large ones, we can turn the second list into a HashSet<T> first, which we’ll see later in the article.

So, we have two lists, one for filtering and one the filtering is based on. With that in mind, let’s look at a high-level view of filtering possibilities: 

  • Select or remove elements from the List 1 that are also present in the List 2
  • Select or remove elements from the List 1 that are not present in the List 2
  • Select or remove elements from List 1 based on the elements in List 2 (e.g. List 2 contains indices of elements to remove)
  • Use List 2 as a mask (for example, boolean values that determine which elements from List 1 to preserve)

Before we proceed, let’s create two lists for use in our first examples:

List<int> intsToFilter = [1, 2, 3, 4, 4, 4, 5, 6, 7, 7, 8, 8, 9];
List<int> filteringIntList = [4, 4, 7, 9, 10];

Let’s start with loops.

Using Loops to Filter a List

One of the first ways in which we can filter lists is via simple loops (like for and foreach). Suppose we want the first list to contain only the elements that are also in the second list. Let’s create a method FilterContainedUsingLoop to handle it. It goes in a static Utilities class, like the other static methods in this article:

public static List<int> FilterContainedUsingLoop(List<int> listToFilter, List<int> filteringList)
{
    List<int> filteredList = [];

    foreach (int item in listToFilter)
    {
        if (filteringList.Contains(item))
            filteredList.Add(item);
    }

    return filteredList;
}

Here we iterate through our listToFilter, adding items to our resulting filteredList as long as the item is contained within our filteringList.

Exercising the method with our previously defined lists results in:

4, 4, 4, 7, 7, 9

Here we retain only the elements from the second list. We could also filter the first list by the elements not contained in the second list:

public static List<int> FilterNotContainedUsingLoop(List<int> listToFilter, List<int> filteringList)
{
    List<int> filteredList = [];

    foreach (int item in listToFilter)
    {
        if (!filteringList.Contains(item))
            filteredList.Add(item);
    }

    return filteredList;
}

This code is similar to the previous method with the exception that now we add items to our resultant filteredList when they are not contained in the filteringList:

1, 2, 3, 5, 6, 8, 8

Filtering without LINQ is possible, as we can see. But LINQ opens up a whole plethora of other possibilities for us.

Filter a List Using the Where Extension Method

We can simplify the examples above by using the Where() extension method. Let’s first filter List 1 by the elements contained in List 2:

public static List<int> FilterContainedUsingWhere(List<int> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(filteringList.Contains).ToList();
}

Here, the Where() method will call filteringList.Contains() on each element of listToFilter, just as we saw in our foreach loop example. Where() returns an IEnumerable<T>, and so to get our final result, we call ToList() to return a new list containing only the elements in listToFilter that were also contained in filteringList:

4, 4, 4, 7, 7, 9

Similarly, we can use the Where() method to filter for elements that are not contained in the second list:

public static List<int> FilterNotContainedUsingWhere(List<int> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(x => !filteringList.Contains(x)).ToList();
}

The filtered list now looks like before:

1, 2, 3, 5, 6, 8, 8

Both methods build a new list and leave intsToFilter as it was. If we want to change the list itself, List<T> has the RemoveAll() method, which takes the opposite condition, the elements to remove:

public static int RemoveNotContained(List<int> listToFilter, List<int> filteringList)
{
    return listToFilter.RemoveAll(item => !filteringList.Contains(item));
}

RemoveAll() returns the number of elements it removed. Let’s call it on a fresh copy of our first list and print the result with PrintList(), a helper in the same class that returns string.Join(", ", list):

List<int> intsToChange = [1, 2, 3, 4, 4, 4, 5, 6, 7, 7, 8, 8, 9];
int removedCount = Utilities.RemoveNotContained(intsToChange, filteringIntList);
Console.WriteLine($"Removed {removedCount}, left: {Utilities.PrintList(intsToChange)}");

Seven elements are gone, and the list now holds the same values FilterContainedUsingWhere() returned:

Removed 7, left: 4, 4, 4, 7, 7, 9

In all of these examples, we explicitly filter elements that are either in or not in the filtering list. But what if we wanted to have just unique values, i.e. not only filter the list, but also remove duplicates? Well, this is pretty easy to do.

Removing Duplicates

The methods we’ve seen thus far retain all filtered values in the first list, even if they are not unique. If we want to remove duplicates, we can add a call to the Distinct() method:

public static List<int> FilterContainedUnique(List<int> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(filteringList.Contains).Distinct().ToList();
}

The Distinct() does just as we would expect, it returns unique elements from a given list. So this time our resultant list will not contain duplicate values:

4, 7, 9

For other ways to drop repeated values, including lists of objects and in-place removal, we have a full guide to removing duplicates from a list.

The Except() extension method removes the second list’s elements and the duplicates in one call.

Filtering Out Matches and Duplicates in One Call

To produce a set difference of two sequences, we can use the Except() extension method:

public static List<int> FilterNotContainedUsingExcept(List<int> listToFilter, List<int> filteringList)
{
    return listToFilter.Except(filteringList).ToList();
}

This method also returns a filtered list that does not contain the elements from List 2:

1, 2, 3, 5, 6, 8

Notice that 8 now appears only once, where FilterNotContainedUsingWhere() returned it twice. Except() treats both lists as sets, and Microsoft Learn states that only unique elements are returned. If we need to keep the repeated values, Where() is the one to use. Our article on the LINQ Except method covers it in depth, including custom equality comparers.

In our examples, the two lists contained elements of the same type. But it doesn’t have to be the case, as we’re about to see.

Filtering Lists of Different Types

The two lists may contain elements of different types. Here’s an example with a list of strings and then a filtering list of integers:

List<string> stringsToFilter = ["2", "5", "10", "20"];
filteringIntList = [5, 8, 12, 20];

Now we can filter the first list for elements that have their counterparts in the second list:

public static List<string> FilterStringsByInts(List<string> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(x => filteringList.Contains(int.Parse(x))).ToList();
}

Here, we Parse() each of the strings into an integer value and check if that value is contained within the filteringList. To keep the example simple, we are intentionally ignoring the potential issue of bad data (i.e. non-integer strings) being supplied in our listToFilter.

The filtered list now only contains those elements that have their integer counterparts in the filtering list:

5, 20

Whether we use lists of elements of identical or different types, we can filter them by custom criteria as well.

Filter a List Using Custom Criteria

We don’t have to filter a list by the elements of another list themselves. Instead, we can use the elements of the filtering list as a criterion.

To illustrate this, let’s create a list of strings and a list of integers:

List<string> animalsToFilter = ["cat", "fox", "horse", "donkey", "snake", "lion"];
List<int> filteringLengthList = [3, 5];

The first list contains the names of animals. The second list contains some integers. Now, let’s keep only the items in List 1 whose string lengths are equal to the numbers in List 2:

public static List<string> FilterAnimalNamesByLengths(List<string> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(animalName => filteringList.Any(length => animalName.Length == length))
                       .ToList();
}

Our resultant list contains only the names that consist of three or five characters:

cat, fox, horse, snake

We can also filter lists containing complex objects. Let’s have a look at that next.

Filter a List of Complex Objects

We don’t have to limit ourselves to simple data types when filtering lists. We can also filter lists of complex objects. Let’s start by defining two classes:

public class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int SchoolId { get; set; }
    public string City { get; set; } = string.Empty;

    override public string ToString()
    {
        return $"{Name} from {City}";
    }
}

public class School
{
    public int Id { get; set; }
    public string City { get; set; } = string.Empty;
}

Now let’s define two lists of objects:

List<Student> studentsToFilter =
[
    new Student { Id = 1, Name = "Alice Straw", SchoolId = 1, City = "Munich" },
    new Student { Id = 2, Name = "Mike McAllen", SchoolId = 1, City = "Munich" },
    new Student { Id = 3, Name = "Bettany Kelly", SchoolId = 2, City = "Essen" },
    new Student { Id = 4, Name = "Sue Heck", SchoolId = 2, City = "Wuppertal" },
    new Student { Id = 5, Name = "Jake Millner", SchoolId = 2, City = "Mainz" },
    new Student { Id = 6, Name = "Allen Claude", SchoolId = 3, City = "Hamburg" }
];

List<School> schoolsToFilter =
[
    new School { Id = 1, City = "Munich" },
    new School { Id = 2, City = "Essen" },
    new School { Id = 3, City = "Hamburg" }
];

Now, let’s filter the first list by the students who live in a city that has one of the schools:

public static List<Student> FilterStudentsBySchoolCity(List<Student> listToFilter, List<School> filteringList)
{
    return listToFilter.Where(student => filteringList.Any(school => student.City == school.City))
                       .ToList();
}

Four out of the six students live in a city with a school:

Alice Straw from Munich, Mike McAllen from Munich, Bettany Kelly from Essen, Allen Claude from Hamburg

Matching Objects on a Key Instead of Any()

FilterStudentsBySchoolCity() calls Any() on the whole school list for every student. LINQ also has methods that match two lists on a key, but they don’t all return the same students.

Let’s start with Join(). It pairs every student with each school that has the same city, and we keep the student from each pair:

public static List<Student> FilterStudentsBySchoolCityUsingJoin(List<Student> listToFilter, List<School> filteringList)
{
    return listToFilter.Join(filteringList,
            student => student.City,
            school => school.City,
            (student, school) => student)
        .ToList();
}

We get the same four students, in the same order:

Alice Straw from Munich, Mike McAllen from Munich, Bettany Kelly from Essen, Allen Claude from Hamburg

Join() returns one result per matching pair, though, so if two schools were in Munich, Alice and Mike would each show up twice. If joins are new to us, our article on the inner join in LINQ walks through them step by step.

IntersectBy(), added in .NET 6, looks like an even shorter way to write the same filter, but it returns fewer students. Don’t use this version when every match matters:

public static List<Student> FilterStudentsBySchoolCityUsingIntersectBy(List<Student> listToFilter, List<School> filteringList)
{
    return listToFilter.IntersectBy(filteringList.Select(school => school.City), student => student.City)
        .ToList();
}

Can you spot what’s missing?

Alice Straw from Munich, Bettany Kelly from Essen, Allen Claude from Hamburg

Mike McAllen is gone. IntersectBy() is a set operation, so it returns one element per distinct key, and Alice already took Munich. ExceptBy() works the same way from the other side. That’s useful when we want one student per city. When we want every student, it loses data without any warning. Our overview of the LINQ improvements in .NET 6 covers IntersectBy(), ExceptBy() and the rest of the key-selector methods.

We can also use specific properties when we filter a list. 

Selecting Specific Properties

We can filter a list and create a list of objects by selecting specific properties. Let’s use the classes we defined before and create a list of students again. But this time, our resulting list will contain only the names of students who attend one of the schools from list 2. We represent the schools in List 2 by their IDs:

List<int> schoolIds = [1, 3];

So, we’re just going to select a specific property of the object, not the entire object. In our case, we select the Name property:

public static List<string> FilterStudentsWithProperties(List<Student> listToFilter, List<int> filteringList)
{
    return listToFilter.Where(student => filteringList.Contains(student.SchoolId))
                       .Select(student => student.Name).ToList();
}

The method returns a list containing the names of the students who attend the schools with Ids 1 or 3:

Alice Straw, Mike McAllen, Allen Claude

Besides selecting particular properties, we can update properties. Let’s see how to do it.

Updating Properties Using a Filter

Let’s say the students with specified IDs move to Berlin. Using the ForEach() method, we can update their City property:

public static void UpdateCityProperty(List<Student> listToFilter, List<int> filteringList)
{
    listToFilter.ForEach(student =>
    {
        if (filteringList.Contains(student.Id))
            student.City = "Berlin";
    });
}

If we now pass a list with IDs 4 and 6 as the second argument, the City property of the students with these IDs will be updated:

Alice Straw from Munich, Mike McAllen from Munich, Bettany Kelly from Essen, Sue Heck from Berlin, Jake Millner from Mainz, Allen Claude from Berlin

If we want to filter a list more efficiently, we can consider using a HashSet.

Filter a List via HashSet

Using a HashSet and set operations can be an effective and efficient way of filtering lists. The reasons behind this are outside the scope of this article, so for a deeper dive, don’t miss our articles HashSet in C# and HashSet vs SortedSet in C#.

In our example below we’re using the IntersectWith() method. This method is specific to sets such as HashSet, and it should be noted that it modifies the existing HashSet. It’s also worth mentioning that HashSet, being a set, will contain only unique values. This means that, for example, if we have a HashSet<int> and add the number 42 to it 4 times, it will only contain one instance of it. 

Now, let’s have a look at how a HashSet can be used to filter a list. First, let’s redefine our two lists of integers:

intsToFilter = [2, 4, 6, 8, 10, 16, 20, 28, 40, 45];
filteringIntList = [0, 10, 20, 30, 40, 50];

Now we want to filter the first list so that it only contains the elements that are also in list 2:

public static List<int> FilterUsingHashSet(List<int> listToFilter, List<int> filteringList)
{
    HashSet<int> hashToFilter = new HashSet<int>(listToFilter);
    hashToFilter.IntersectWith(filteringList);

    return hashToFilter.ToList();
}

When we execute our code, we get the elements that are in both collections:

10, 20, 40

IntersectWith() leaves us with a set, so the duplicates from the first list are gone, and a HashSet<T> doesn’t promise to keep its elements in any particular order. Often we want the opposite: the first list exactly as it was, minus the elements we don’t want.

In that case, we turn the filtering list into a HashSet<T> and keep using Where():

public static List<int> FilterContainedUsingHashSetLookup(List<int> listToFilter, List<int> filteringList)
{
    HashSet<int> lookup = filteringList.ToHashSet();

    return listToFilter.Where(lookup.Contains).ToList();
}

With the lists from this section, we get 10, 20, 40 again. With the two lists from the beginning of the article, we get the same result as our loop, duplicates and order included:

4, 4, 4, 7, 7, 9

The only difference from FilterContainedUsingWhere() is where Contains() looks. Microsoft Learn describes List<T>.Contains() as a linear search, “an O(n) operation, where n is Count”, so every element of the first list can scan the whole second list. HashSet<T>.Contains() is an O(1) operation, a single hash lookup per element, and building the set costs one pass over the filtering list.

Last but not least, let’s see how to use masks to filter lists.

Using Masks to Filter a List

Generally, masks are lists of boolean values used to include or exclude data from a stream or collection. In our case, we can use them to filter lists.

Let’s redefine our list of integers and create a mask:

intsToFilter = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
List<bool> mask = [true, false, false, false];

We can treat the mask as a cyclic pattern. If the mask contains fewer items than the list, we simply cycle over it. So, in our case, the pattern is:

true, false, false, false, true, false, false, false, true, false, false, false...

We compare the elements in the first list and the mask index by index:

public static List<int> FilterUsingMask(List<int> listToFilter, List<bool> mask)
{ 
    return listToFilter.Where((item, index) => mask[index % mask.Count]).ToList();
}

With this approach, only the integers that correspond to the value true in the mask are retained:

1, 5, 9, 13

We’ve seen quite a few ways to filter one list by another, so let’s put them side by side.

Which Way of Filtering a List Should We Use?

Most of the time, Where() with Contains() is the right choice. It reads almost like the sentence we’d say out loud, keeps the first list’s order and its duplicates, and leaves both lists untouched.

Three questions tell us when to reach for something else.

Is the filtering list large? Then we build a HashSet<T> from it once and call its Contains() inside Where(). The result doesn’t change, but each check no longer scans the second list.

Do we want to change the original list instead of creating a new one? RemoveAll() filters in place and tells us how many elements it removed.

Do we want unique values? Except(), IntersectWith(), IntersectBy() and ExceptBy() are set operations. They remove duplicates whether we ask for it or not, which is handy when that’s the goal and loses data without warning when it isn’t.

For lists of objects matched on a key, Join() keeps every match.

ApproachGives usKeeps list 1's orderKeeps duplicatesChanges list 1
foreach with List<T>.Contains()a new List<T>YesYesNo
Where() with List<T>.Contains()a new list after ToList()YesYesNo
Where() with HashSet<T>.Contains()a new list after ToList()YesYesNo
List<T>.RemoveAll()the number of elements removedYesYesYes
Except()a new list after ToList()Yes, in the sample runNoNo
HashSet<T>.IntersectWith()a set, then a listNot guaranteedNoNo, it changes the set copy
IntersectBy() / ExceptBy()a new list after ToList()Yes, in the sample runNo, one element per keyNo
Join()a new list after ToList()YesYes, and one copy per extra matchNo
Where() with an index and a maska new list after ToList()YesYesNo

The custom-criteria approach with Any() doesn’t need a row of its own. It works like Where() with Contains(), just with whatever condition we write in the lambda.

Conclusion

Filtering lists is a common operation. We looked at different ways of filtering a list based on a second list, and at which of them keep the first list’s order and duplicates. While not exhaustive, our examples covered the most typical cases. What did we miss? Let us know in the comments below!

Tested with .NET 10.0.10 and xUnit 2.9.3.