DEV Community

Cover image for Comparing two lists of 1 million objects in 0.7 s in .NET, and why the result should be data, not a boolean
Youssef Bouha
Youssef Bouha

Posted on Fully Autonomous

Comparing two lists of 1 million objects in 0.7 s in .NET, and why the result should be data, not a boolean

Every release of a pricing or risk engine raises the same question: does the new version produce the same numbers as the old one? The usual answer is a non-regression test (NRT): run yesterday's build and today's build on the same input, then compare millions of output rows.

Most NRT harnesses I have seen answer that question with a boolean, or with a hash of the whole output. "Different" is not an answer you can act on. What you need is which rows differ, on which properties, with what values, and which rows are missing on either side.

That is what Xero.DataComparer does. It is a small, dependency-free .NET library (net48, netstandard2.0, net8.0, net10.0) that I extracted from tooling I built over the years on capital-markets and energy-trading platforms. This post walks through the API and the few design choices that make it fast.

The API in ten lines

using Xero.DataComparer.Core;

var comparer = new ListComparer<RiskIndicator>(
    keyProperties:    ["TradeId", "Book", "AsOfDate"],   // how rows are matched
    ignoreProperties: ["LastUpdated", "RunId"]);         // volatile fields

CompareResult<RiskIndicator> result = await comparer.CompareListAsync(reference, target);

Console.WriteLine($"Only in reference : {result.OnlyInReference.Count}");
Console.WriteLine($"Only in target    : {result.OnlyInTarget.Count}");
Console.WriteLine($"In both, differing: {result.Count}");
Enter fullscreen mode Exit fullscreen mode

You give it the key properties that identify the same row on both sides, the properties to ignore, and two List<T>. Every other public property of T is compared.

What you get back

CompareResult<T> holds:

Slice Content
OnlyInReference / OnlyInTarget The original objects present on one side only.
DuplicatesInReference / DuplicatesInTarget Items whose key was already seen on the same side.
the result itself One flat row per matched pair that differs on at least one property.
Timings Start and end of each step, for your own instrumentation.

A diff row is a small dictionary laid out for humans and tools:

Key_TradeId = TRD-000980002   Key_Book = FX-G10   Key_AsOfDate = 2026-04-30
Reference_PnL = 44,362.94     Target_PnL = 44,352.16
Reference_VaR = 49,823.45     Target_VaR = 49,860.02
ComparedItems = (reference object, target object)
Enter fullscreen mode Exit fullscreen mode

Only the properties that actually differ are stored, and ComparedItems keeps both original objects so you can navigate to any other field.

The best part: CompareResult<T> implements ITypedList. Its columns are derived from T (every key, every compared property, typed), so you can assign it to a WinForms DataGridView, a DevExpress GridControl or any data-bound grid, and the columns appear with no view model and no column setup. The repository includes a WinForms demo built exactly on that:

Xero.DataComparer WinForms demo

The silent bug: duplicate keys

One thing an NRT must never do is lose rows silently. If two rows share the same key on the same side, a naive dictionary-based comparison keeps one and drops the other, and the report says "identical". In a regulatory or P&L context, that is the worst possible outcome.

Xero.DataComparer returns those rows in DuplicatesInReference / DuplicatesInTarget and logs a warning. They are left out of the matching (there is no correct way to pair them), but they are never lost.

How it is fast

On a 16-logical-core machine, comparing two lists of 1,000,000 objects takes about 0.7 s and allocates about 53 MB. Two lists of 5,000,000 take about 4.3 s. Four choices get it there.

1. Reflection once, typed delegates per row

Property access is compiled once with System.Linq.Expressions. The important detail is that the delegates are typed: equality and hash code use EqualityComparer<TProperty>.Default on the real property type, so comparing two double values never boxes them.

private static PropertyAccessor<T> CreateTyped<TProperty>(PropertyInfo property)
{
    var item = Expression.Parameter(typeof(T), "item");
    var getter = Expression.Lambda<Func<T, TProperty>>(Expression.Property(item, property), item).Compile();
    var comparer = EqualityComparer<TProperty>.Default;
    return new PropertyAccessor<T>(
        property,
        x => getter(x),                                          // boxes: only used for a diff row
        (x, y) => comparer.Equals(getter(x), getter(y)),         // no boxing
        x => getter(x) is { } value ? comparer.GetHashCode(value) : 0);
}
Enter fullscreen mode Exit fullscreen mode

CreateTyped<TProperty> is called once per property through MakeGenericMethod. After that, values are boxed only when a property actually differs, to be stored in the diff row.

Switching from Func<T, object> getters to typed accessors took the 1M-row comparison from 1.4 s and 603 MB allocated to 0.75 s and 53 MB.

2. A dual hash set, sized up front, hash computed once

Both lists go into a purpose-built HashSetComparer<T>: two hash indexes sharing the same key comparer, with prime-sized buckets allocated from the known list sizes, so there is never a resize. Each item's hash code is stored when it is added and reused to look it up in the other index; it is never recomputed.

3. Everything that can run in parallel does

await Task.WhenAll(
    Task.Run(() => AddAll(referenceItems, set.AddLeftItem)),
    Task.Run(() => AddAll(targetItems,   set.AddRightItem)));

Parallel.Invoke(
    () => onlyInTarget    = set.OnlyInRight().ToList(),
    () => onlyInReference = set.OnlyInLeft().ToList(),
    () => inBothButDiff   = CompareInBoth(set.InBoth()));   // itself PLINQ
Enter fullscreen mode Exit fullscreen mode

The two indexes are built at the same time, then the three result sets are produced at the same time, and the property comparison of matched pairs is spread over all cores with PLINQ. result.Timings shows how much the steps overlap; in the screenshot above, 1,559 ms of work runs in 676 ms of wall clock.

4. Tiny diff rows

A diff row holds at most keys + 2 × compared properties + 1 entries, typically 10 to 30. At that size a linear scan beats hashing, so rows are a small PooledDictionary sized exactly to the column count, and the column names (Reference_PnL, ...) are built once per comparer, not once per row.

A benchmarking trap worth knowing

While measuring this, my first numbers were terrible: 7.9 s and 7.4 GB for 1M rows. The cause had nothing to do with the library. File-based apps (dotnet run app.cs) default to PublishAot=true, and under that setting Expression.Compile() falls back to the interpreter. Every "compiled" getter call was interpreted and allocated. Adding #:property PublishAot=false at the top of the benchmark file fixed it. If you use expression trees in a file-based app, check this first.

Try it

dotnet add package Xero.DataComparer
Enter fullscreen mode Exit fullscreen mode

Feedback, issues and benchmarks on your own data are very welcome.

Top comments (0)