Updated November 2026: rewritten for .NET 10 and BenchmarkDotNet 0.15.8, with [MemoryDiagnoser] and freshly measured results. LINQ has become much faster since the first version of this article.

Benchmarking measures how fast code runs, so that you can compare implementations and find what is worth optimising. In this article, we create a benchmark in C# that compares three ways to find an element by its id in a list:

Prerequisites

BenchmarkDotNet

BenchmarkDotNet is an open-source library that turns methods into benchmarks. It handles warm-up, repeated measurements and statistics, and prints the results as a table. It supports .NET and .NET Framework.

Create a new project

  1. Create a folder named IntroductionToBenchmarkDotNet and open a terminal in it.
  2. Create a console app (dotnet new):
Copy
dotnet new console --framework net10.0
  1. Add the BenchmarkDotNet package:
Copy
dotnet add package BenchmarkDotNet --version 0.15.8

Create a benchmark

Add a benchmark class

We need a Person class with an Id and a Name, and a GetByIdBenchmark class that holds a list of people:

Copy
namespace IntroductionToBenchmarkDotNet.Models;

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}
Copy
using BenchmarkDotNet.Attributes;
using IntroductionToBenchmarkDotNet.Models;

namespace IntroductionToBenchmarkDotNet.Benchmarks;

[MemoryDiagnoser]
public class GetByIdBenchmark
{
    readonly List<Person> people = [];
    readonly int id = 1;

    [Params(10, 50, 100)]
    public int Iterations { get; set; }

    [GlobalSetup]
    public void GlobalSetup()
    {
        for (int i = 1; i <= Iterations; i++)
        {
            var person = new Person { Id = i, Name = $"Name{i}" };
            people.Add(person);
        }
    }
}

This class uses three BenchmarkDotNet attributes:

  • MemoryDiagnoser adds an Allocated column to the results: the managed memory allocated by a single call.
  • Params takes a set of values. BenchmarkDotNet runs each benchmark once per value.
  • The GlobalSetup method runs once per benchmark case (a method combined with a parameter value), before the measurements start. It prepares the same data for every benchmark, and its own duration is not measured.

Note: GlobalCleanup runs once after each benchmark case, for example to dispose a resource. IterationSetup runs before every iteration, but the documentation reserves it for macrobenchmarks, which take at least 100 ms.

Add the methods to compare

Add the three lookups to GetByIdBenchmark. The Benchmark attribute marks each method to measure:

Copy
[Benchmark]
public Person? Foreach()
{
    foreach (var person in people)
    {
        if (person.Id == id)
            return person;
    }

    return null;
}

[Benchmark]
public Person? FirstOrDefault()
    => people.FirstOrDefault(x => x.Id == id);

[Benchmark]
public Person? SingleOrDefault()
    => people.SingleOrDefault(x => x.Id == id);

Run the benchmark

BenchmarkRunner runs every benchmark in a class. Replace the content of Program.cs:

Copy
using BenchmarkDotNet.Running;
using IntroductionToBenchmarkDotNet.Benchmarks;

BenchmarkRunner.Run<GetByIdBenchmark>();

Then run the project in Release mode:

Copy
dotnet run -c Release

Note: -c (--configuration) sets the build configuration, which defaults to Debug (dotnet run). Always benchmark in Release: a Debug build is not optimised, so it measures code that never runs in production. BenchmarkDotNet refuses to run a non-optimised assembly by default.

The default job warms up and measures each case many times: the 9 cases took about 14 minutes on my 4-core Linux virtual machine. Your numbers depend on your hardware. Here are mine:

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
Intel Xeon Processor 2.10GHz, 1 CPU, 4 logical and 4 physical cores
.NET SDK 10.0.114
  [Host]     : .NET 10.0.14 (10.0.14, 10.0.1426.52326), X64 RyuJIT x86-64-v4
  DefaultJob : .NET 10.0.14 (10.0.14, 10.0.1426.52326), X64 RyuJIT x86-64-v4


| Method          | Iterations | Mean       | Error     | StdDev    | Median     | Allocated |
|---------------- |----------- |-----------:|----------:|----------:|-----------:|----------:|
| Foreach         | 10         |  1.1197 ns | 0.1330 ns | 0.3902 ns |  1.2465 ns |         - |
| FirstOrDefault  | 10         |  1.7252 ns | 0.1407 ns | 0.4105 ns |  1.7994 ns |         - |
| SingleOrDefault | 10         |  5.6789 ns | 0.2852 ns | 0.8408 ns |  5.9378 ns |         - |
| Foreach         | 50         |  0.8531 ns | 0.1090 ns | 0.3214 ns |  0.9066 ns |         - |
| FirstOrDefault  | 50         |  1.8664 ns | 0.1616 ns | 0.4766 ns |  1.9436 ns |         - |
| SingleOrDefault | 50         | 31.3460 ns | 1.1564 ns | 3.4096 ns | 32.2358 ns |         - |
| Foreach         | 100        |  1.0793 ns | 0.1008 ns | 0.2972 ns |  1.1098 ns |         - |
| FirstOrDefault  | 100        |  1.8025 ns | 0.1535 ns | 0.4526 ns |  1.9119 ns |         - |
| SingleOrDefault | 100        | 48.4199 ns | 1.5223 ns | 4.4885 ns | 50.2168 ns |         - |

Mean is the arithmetic mean of all measurements, Error half of the 99.9% confidence interval, StdDev their standard deviation and Median the 50th percentile. Allocated is the managed memory allocated per operation (- means none), and 1 ns is 0.000000001 s.

What the Mean column tells us:

  • foreach is the fastest, and FirstOrDefault is less than 1 ns behind. The list size does not matter for either, because the id we look for (1) is the first element, so both stop immediately.
  • SingleOrDefault is the only one that grows with the list (about 6 ns for 10 elements, 48 ns for 100): it scans the whole list to check that the match is unique.
  • None of the three methods allocates on .NET 10.

Note: At 1 or 2 ns, measurements are noisy. On my virtual machine, BenchmarkDotNet warned about multimodal distributions and removed a few outliers. Do not base a decision on a gap of a fraction of a nanosecond.

When I first wrote this article with .NET 6, on another machine, FirstOrDefault took about 23 ns, roughly 20 times slower than foreach, and SingleOrDefault about 780 ns for 100 elements. LINQ has been optimised release after release (the .NET team describes each round in its yearly posts, such as Performance Improvements in .NET 10), so my old advice to avoid LINQ here no longer holds. Use FirstOrDefault for the first match, SingleOrDefault only when you need the uniqueness check, and measure before you rewrite readable LINQ as a loop.

Bonus: compare runtimes

A benchmark can run on several runtimes. To compare the two current LTS versions, .NET 8 and .NET 10, make the project target both frameworks in the .csproj file. Replace:

Copy
<TargetFramework>net10.0</TargetFramework>

with:

Copy
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>

Then add one SimpleJob attribute per runtime to GetByIdBenchmark:

Copy
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using IntroductionToBenchmarkDotNet.Models;

namespace IntroductionToBenchmarkDotNet.Benchmarks;

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net10_0)]
[SimpleJob(RuntimeMoniker.Net80)]
public class GetByIdBenchmark
...

Note: Each runtime you benchmark must be installed. The .NET 10 moniker is Net10_0, not Net100.

Run the project with the host framework set:

Copy
dotnet run -c Release --framework net10.0

--framework net10.0 only selects the runtime that hosts the console app. BenchmarkDotNet still runs both jobs, and the summary gets a Runtime column to compare them.

Note: Only .NET 10 was installed on my machine for this update: I checked that this project builds, but I did not run the .NET 8 job, so there is no comparison table here.

Conclusion

BenchmarkDotNet measures your code and summarises the results in a readable table. It is the standard benchmarking library of the .NET ecosystem: the .NET team uses it in dotnet/performance for the runtime and libraries.

As the LINQ results show, a benchmark result does not last forever: run your benchmarks again when you upgrade .NET.

Source code of the article: GitHub source code

References