Updated on
Combining two lists in C# has a default answer: AddRange() into a list we created with room for both. The [.. firstList, .. secondList] collection expression gets us the same list in one line.
The other approaches earn their place for reasons other than speed. Concat() keeps the result lazy, Union() drops duplicates, CopyTo() goes through an array, and SelectMany() flattens any number of lists at once. We cover seven approaches and benchmark them.
Combine Two Lists With List.Add
Let’s create a Concatenator class, which holds every method in this article, and start with a UsingAdd method to concatenate two lists:
public class Concatenator
{
public List<string> UsingAdd(List<string> firstList, List<string> secondList)
{
var result = new List<string>(firstList.Count + secondList.Count);
foreach (var item in firstList)
{
result.Add(item);
}
foreach (var item in secondList)
{
result.Add(item);
}
return result;
}
}
This method receives two lists as input parameters that we want to concatenate.
List works as a reference type, so if we modify it within our method, every reference sees the change. For this reason, changing one of the input lists is not a good practice. You can read more about lists in our article List Collection in C#.
That said, we instantiate a new string list to represent the result of our method. Also, we set up the list’s capacity to the sum of the two lists’ lengths we want to combine.
In the second step, we iterate through the firstList and add each element to the result list. Then, we repeat the process for the secondList.
Once we have iterated through the two lists, we return the result list containing the elements of both lists.
Combine Two Lists With Enumerable.Concat
We can use three extension methods in the Enumerable static class to concatenate two list elements. The first one is Enumerable.Concat:
public List<string> UsingEnumerableConcat(List<string> firstList, List<string> secondList)
{
return Enumerable.Concat(firstList, secondList).ToList();
}
We simply call the Concat method from the Enumerable static class. This method requires two IEnumerable input parameters and returns a new IEnumerable containing every element of the input lists.
After that, we call the ToList() method and return the result.
Does Union Combine Two Lists or Remove Duplicates?
Union() does both. It combines the two lists and removes every duplicate along the way, and which of the two we notice depends on our data.
Microsoft Learn defines Enumerable.Union() as producing the set union of two sequences using the default equality comparer. A set union holds every distinct value once, so duplicates disappear across the two lists and inside each one.
So Union() is a set operation. Combining a list of ten thousand names with another list of ten thousand names gives us twenty thousand elements through AddRange() and somewhere between one and twenty thousand through Union().
Union() is also deferred. The call itself compares nothing, and the work happens when something enumerates the result, which is what the ToList() call at the end of our method does.
So we use Union() when we want a combined list with no repeats, and one of the other six approaches when we want everything both lists hold.
The second method that we can use from the Enumerable static class is the Union method:
public List<string> UsingEnumerableUnion(List<string> firstList, List<string> secondList)
{
return Enumerable.Union(firstList, secondList).ToList();
}
The Union method receives two parameters (firstList and secondList). After calling this method, it is necessary to call the ToList() method to create a new list from the IEnumerable and return it.
It is important to mention that Union method removes duplicated elements from its result.
The Enumerable.Union reference on Microsoft Learn says the same: “This method excludes duplicates from the return set.”
This method also provides an overloaded method with a third parameter representing an IEqualityComparer to compare the elements within the two lists.
Combine Two Lists With AddRange
Let’s implement a UsingAddRange method to concatenate two lists elements:
public List<string> UsingAddRange(List<string> firstList, List<string> secondList)
{
var result = new List<string>(firstList.Count + secondList.Count);
result.AddRange(firstList);
result.AddRange(secondList);
return result;
}
The UsingAddRange method receives two lists as input parameters and returns a new list with the combined elements from the two lists.
First, we instantiate an object (result) that is going to be our method’s return.
Then, we call the AddRange method to add every element from the firstList inside the result object and repeat the process with the secondList.
Finally, we return the result list with every element from both lists.
Similarly to the UsingAdd method, it is not a good practice to call the AddRange method against one of the input lists because we don’t want to change it.
Combine Two Lists With List.CopyTo
Let’s create a UsingCopyTo method to concatenate two lists using an array:
public List<string> UsingCopyTo(List<string> firstList, List<string> secondList)
{
var combinedArray = new string[firstList.Count + secondList.Count];
firstList.CopyTo(combinedArray, 0);
secondList.CopyTo(combinedArray, firstList.Count);
return combinedArray.ToList();
}
First, we instantiate a new array (combinedArray) and set its length to the sum of both input lists’ lengths.
Then, we copy the first list of elements into the combinedArray starting at index 0.
After that, we repeat the process with the secondList, but this time, the start index needs to be the firstList.Count (after the last element from the firstList).
Finally, we call ToList() to return a new list of strings based on the combinedArray.
Combine Two Lists With SelectMany
Let’s concatenate two lists using SelectMany method:
public List<string> UsingSelectMany(List<string> firstList, List<string> secondList)
{
var combinedArray = new[] { firstList, secondList }.SelectMany(x => x);
return combinedArray.ToList();
}
This approach consists of instantiating a new array and filling it with the two input parameter lists (firstList and secondList).
Then we call the SelectMany method to flatten the result into a single IEnumerable, which we store in combinedArray. The SelectMany method receives a Func delegate to apply to each element of that array.
Finally, we return a new list calling the ToList() method on the combinedArray.
Combine Two Lists With a Collection Expression
C# 12 added collection expressions, and their spread element .. turns combining two lists into a single expression:
public List<string> UsingCollectionExpression(List<string> firstList, List<string> secondList)
{
List<string> result = [.. firstList, .. secondList];
return result;
}
The spread element inlines the values of a collection into the collection expression, so [.. firstList, .. secondList] produces every element of the first list followed by every element of the second. Any expression we can foreach over can be spread, so the same syntax works with lists, arrays and IEnumerable<T> results, or a mix of them.
We go through the syntax in more depth in our article on simple initialization with collection expressions, and our overview of collection expressions and the rest of C# 12 covers the other additions in that version.
Which Way of Combining Two Lists Is Fastest?
AddRange() into a list we pre-sized, Enumerable.Concat() and the collection expression finish level in our runs, close enough that BenchmarkDotNet ranks all three first at both list sizes.
Pre-sizing matters more than the choice between those three. Creating the result as new List<string>(firstList.Count + secondList.Count) allocates the backing array once. Without it, the list grows by doubling and copies everything it already holds each time it grows, and AddRange() then takes about 50% longer than Concat().
CopyTo() copies every element twice, once into the array and again in ToList(). The Add() loop moves one element per call, where AddRange() copies a whole list at once.
Union() is last at both sizes, about 40 times slower than AddRange(), because it hashes every element to look for repeats even when there are none.
We ran the benchmark on .NET 10.0.10 with BenchmarkDotNet 0.15.8, on two lists of 50,000 and then 1,000,000 distinct strings.
We add the BenchmarkDotNet package to the project and put our benchmark class in a file of its own. It builds two lists of distinct strings for each size, so every approach, Union() included, returns all of their elements:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
[RankColumn]
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class ConcatenatorBenchmark
{
[Params(50_000, 1_000_000)]
public int Size;
private List<string> _firstList = [];
private List<string> _secondList = [];
private readonly Concatenator _concatenator = new();
[GlobalSetup]
public void Setup()
{
_firstList = Enumerable.Range(0, Size).Select(i => $"Code{i}").ToList();
_secondList = Enumerable.Range(0, Size).Select(i => $"Maze{i}").ToList();
}
[Benchmark]
public void UsingAddRange()
{
_concatenator.UsingAddRange(_firstList, _secondList);
}
}
The class has one [Benchmark] method for each approach, plus the two capacity-free methods from the next section, and each one calls the matching Concatenator method the way UsingAddRange() does here. To run it, Program.cs holds using BenchmarkDotNet.Running; and BenchmarkRunner.Run<ConcatenatorBenchmark>(args: args);, and we start the app with dotnet run -c Release -- --maxIterationCount 30. Our runs capped BenchmarkDotNet at 30 measured iterations per benchmark, which is the MaxIterationCount=30 line in the output.
Let’s start with the results for two lists of 50 thousand elements:
BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update) AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores .NET SDK 10.0.302 [Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3 Job-DELOGH : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3 MaxIterationCount=30 | Method | Size | Mean | Error | StdDev | Rank | Gen0 | Gen1 | Gen2 | Allocated | |-------------------------- |------ |-----------:|---------:|----------:|-----:|---------:|---------:|---------:|-----------:| | UsingCollectionExpression | 50000 | 102.2 us | 1.91 us | 1.79 us | 1 | 44.0674 | 43.9453 | 43.9453 | 781.64 KB | | UsingAddRange | 50000 | 103.2 us | 1.30 us | 1.21 us | 1 | 44.5557 | 44.5557 | 44.5557 | 781.63 KB | | UsingEnumerableConcat | 50000 | 104.5 us | 2.49 us | 3.73 us | 1 | 43.5791 | 43.5791 | 43.5791 | 781.66 KB | | UsingAddRangeNoCapacity | 50000 | 159.6 us | 3.17 us | 3.53 us | 2 | 67.3828 | 67.3828 | 67.3828 | 1172.46 KB | | UsingSelectMany | 50000 | 177.3 us | 2.30 us | 2.15 us | 3 | 48.0957 | 48.0957 | 48.0957 | 781.74 KB | | UsingCopyTo | 50000 | 233.7 us | 4.56 us | 4.27 us | 4 | 102.5391 | 102.2949 | 102.2949 | 1563.37 KB | | UsingAdd | 50000 | 410.0 us | 3.45 us | 2.88 us | 5 | 110.8398 | 110.8398 | 110.8398 | 782.11 KB | | UsingAddNoCapacity | 50000 | 585.7 us | 8.29 us | 7.35 us | 6 | 230.4688 | 214.8438 | 214.8438 | 2049.69 KB | | UsingEnumerableUnion | 50000 | 4,048.1 us | 80.01 us | 104.04 us | 7 | 937.5000 | 906.2500 | 906.2500 | 6678.89 KB |
We can see that UsingCollectionExpression, UsingAddRange and UsingEnumerableConcat share the fastest rank, and their error ranges overlap. The slowest approach, UsingEnumerableUnion, takes about 39 times as long as UsingAddRange.
Here are the results for two lists of 1 million elements:
BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update) AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores .NET SDK 10.0.302 [Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3 Job-DELOGH : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3 MaxIterationCount=30 | Method | Size | Mean | Error | StdDev | Rank | Gen0 | Gen1 | Gen2 | Allocated | |-------------------------- |-------- |-----------:|----------:|----------:|-----:|---------:|---------:|---------:|----------:| | UsingCollectionExpression | 1000000 | 3.351 ms | 0.0504 ms | 0.0421 ms | 1 | 54.6875 | 54.6875 | 54.6875 | 15.26 MB | | UsingAddRange | 1000000 | 3.430 ms | 0.0507 ms | 0.0474 ms | 1 | 54.6875 | 54.6875 | 54.6875 | 15.26 MB | | UsingEnumerableConcat | 1000000 | 3.432 ms | 0.0492 ms | 0.0385 ms | 1 | 54.6875 | 54.6875 | 54.6875 | 15.26 MB | | UsingAddRangeNoCapacity | 1000000 | 5.188 ms | 0.0828 ms | 0.0814 ms | 2 | 78.1250 | 78.1250 | 78.1250 | 22.89 MB | | UsingSelectMany | 1000000 | 6.800 ms | 0.1356 ms | 0.1507 ms | 3 | 70.3125 | 70.3125 | 70.3125 | 15.26 MB | | UsingCopyTo | 1000000 | 7.448 ms | 0.1403 ms | 0.1312 ms | 4 | 117.1875 | 117.1875 | 117.1875 | 30.52 MB | | UsingAdd | 1000000 | 8.175 ms | 0.1552 ms | 0.1594 ms | 5 | 109.3750 | 109.3750 | 109.3750 | 15.26 MB | | UsingAddNoCapacity | 1000000 | 11.627 ms | 0.2479 ms | 0.3634 ms | 6 | 203.1250 | 187.5000 | 187.5000 | 32 MB | | UsingEnumerableUnion | 1000000 | 139.101 ms | 5.1454 ms | 7.7015 ms | 7 | 333.3333 | 333.3333 | 333.3333 | 121.84 MB |
Now that we are running a much heavier benchmark, we can see that our result isn’t in us (microsecond 0.000001 sec) anymore but in ms (0.001 sec).
The order of the approaches remains the same, and the slowest approach takes about 42 times as long as the fastest one.
Considering both benchmarks’ results (50 thousand elements and 1 million elements), the difference between the fastest and the third-fastest approach is irrelevant (2.3 us and 0.081 ms, respectively).
List Capacity Performance Impact
In C#, the List generic class has three constructors. In one of them, it is possible to specify the list’s capacity, as we did in the UsingAdd and UsingAddRange methods.
To see what the capacity buys us, the benchmark also runs two copies of those methods with the capacity removed from the constructor. Here is UsingAddRangeNoCapacity, and UsingAddNoCapacity makes the same change to UsingAdd:
public List<string> UsingAddRangeNoCapacity(List<string> firstList, List<string> secondList)
{
var result = new List<string>();
result.AddRange(firstList);
result.AddRange(secondList);
return result;
}
Note that when we don’t set the list’s capacity, the UsingAddRange approach loses its place at the top and becomes 53% slower than the UsingEnumerableConcat approach with two lists of 50 thousand elements. Also, it becomes 55% slower than when we set the list’s capacity.
With two lists of 1 million elements, the difference between UsingAddRangeNoCapacity and UsingEnumerableConcat stays about the same, since UsingEnumerableConcat is 34% faster.
When using the predefined list’s capacity, the UsingAddRange approach is 34% faster than when we are using the non-predefined list’s capacity.
The same applies to the UsingAdd approach, which takes 30% less time when we set up the list’s capacity.
The memory allocation increases from 15.26 MB to 22.89 MB in the UsingAddRange approach and from 15.26 MB to 32 MB in the UsingAdd approach when we compare the predefined and non-predefined list’s capacity results.
Which Way Should We Combine Two Lists in C#?
Four questions decide it, and speed is not one of them, because the top three approaches finish within microseconds of each other even with a million elements per list.
Do we want a new list, or do we want to grow one we already have? AddRange() on an existing list changes that list, which saves allocating a second one and surprises whoever else holds the same reference.
Do we want every element, or only the distinct ones? AddRange(), Concat(), CopyTo(), SelectMany(), the Add() loop and a collection expression keep everything. Union() keeps one of each.
Do we need the elements now, or is a sequence enough? Concat() hands back something we can filter further before we materialize it, and calling ToList() immediately throws that option away.
How many lists are there? A collection expression reads best for two or three. SelectMany() is the one that takes a list of lists.
Here are the seven approaches side by side:
| Approach | What we get back | Keeps duplicates | Reach for it when |
|---|---|---|---|
[.. first, .. second] | a new List<string> | yes | we want the whole result in one line, C# 12 or later |
result.AddRange(first) | the list we called it on, grown | yes | we already own the target list |
first.Concat(second) | a lazy IEnumerable<string> | yes | more LINQ follows before we materialize |
first.Union(second) | a lazy IEnumerable<string>, distinct | no | we want a combined list with no repeats |
first.CopyTo(array, 0) | an array we allocated | yes | an array is what the next call needs |
new[] { first, second }.SelectMany(x => x) | a lazy IEnumerable<string> | yes | there are more than two lists, or a list of lists |
foreach + result.Add(item) | a new List<string> | yes | never, for this job: AddRange() does it in one call |
Every row combines two List<string> values like the ones in our examples. This article covers lists only. To do the same with arrays, see how to merge and concatenate arrays in C#, and to turn a list into one string instead of another list, see how to join a list’s elements into one comma separated string.
Conclusion
In this article, we have shown seven different ways to concatenate lists in C#.
Also, we ran benchmarks that put AddRange(), Concat() and the collection expression level at the top, with Union() the slowest because it checks every element for repeats. However, we can’t discard the Union method, because it can be useful in scenarios where we need to remove duplicates.
Finally, we have seen that sometimes, using the list’s constructor with capacity is very useful to make our code more efficient: the versions without it took 42% to 55% longer in our runs.
Tested with .NET 10.0.10 and BenchmarkDotNet 0.15.8.

Using the .Count() extension method is slower than using the built-in .Count property. Making this change would slightly improve the performance of the affected cases.
If the type implements ICollection (as List<T> does)
Count()will just use the Count property. Therefore the speed should be roughly the same aside from a very minor overhead due to the type checks.Interesting results. I would have guessed
AddRangeis the fastest — that’s its purpose in life. The big surprise is thatAddin a loop came in so far down the list. I had assumed thatAddRangeis implemented as Add in a loop. Checking the source code, it’ll use an Array.Copy in the list’s internal array if the source is an ICollection. ANd even if the source is just a true IEnumerable, AddRange only checks the capacity once, rather than on every item as withAdd.The other big surprise is the high score for Enumerable.Concat. (BTW, Concat is an extension method, so it could be called as
firstList.Concat(secondList).ToList();). The Concat returns just a simple IEnumerable — there are no optimizations available based on an underlying type. Basically, it’s just Adds in a loop — yet it comes out twice as fast as that option on the benchmarks.Enumerable.Concatalso does optimizations if the underlying types implement ICollection<T>, see runtime/Concat.cs at main ยท dotnet/runtime ยท GitHub