DEV Community

Cover image for .NET Reflection Performance: CSV Export Benchmarks (Part 2)
Daniel Balcarek
Daniel Balcarek

Posted on

.NET Reflection Performance: CSV Export Benchmarks (Part 2)

.NET Reflection is often criticized for its performance overhead, but its real impact depends heavily on how and where it is used.

In Part 1, we benchmarked reflection in a simple scenario, retrieving enum attributes and demonstrated that caching can significantly reduce its cost.

In this part, we move to a more practical and demanding use case: exporting objects to formats such as CSV or Excel. This typically involves processing large collections, where even small per call overheads can accumulate into measurable performance differences.

The goal is to evaluate how reflection behaves under these conditions and compare it with alternative approaches to understand when it becomes a bottleneck and when it doesn’t.

CSV Export

Exporting data to formats such as CSV, XLSX and ODS is standard in many business applications. In this scenario, we utilize reflection within a generic export method, leveraging custom attributes that represent headers.

Reflection Implementation

A basic extension method for exporting objects to CSV files might look like this:

public static class GenericCsvExport
{
    public static string ExportToCsv<T>(this IEnumerable<T> items, string separator = ";") where T : class
    {
        ArgumentNullException.ThrowIfNull(items);

        var properties = TypePropertiesCache<T>.Properties;
        var lastIndex = properties.Length - 1;

        var result = new StringBuilder();
        result.Append(string.Join(separator, TypePropertiesCache<T>.HeaderNames));
        result.AppendLine();

        foreach (var item in items)
        {
            for (var i = 0; i < properties.Length; i++)
            {
                AppendValue(result, properties[i].GetValue(item));

                if (i < lastIndex)
                    result.Append(separator);
            }
            result.AppendLine();
        }

        return result.ToString();
    }

    private static void AppendValue(StringBuilder sb, object? value)
    {
        if (value is string str)
            sb.Append(str);
        else if (value is IFormattable formattable)
            sb.Append(formattable.ToString(null, CultureInfo.InvariantCulture));
        else
            sb.Append(value);
    }

    private static class TypePropertiesCache<T> where T : class
    {
        public static readonly PropertyInfo[] Properties;
        public static readonly string[] HeaderNames;

        static TypePropertiesCache()
        {
            Properties = typeof(T).GetProperties();

            if (Properties.Length == 0)
                throw new InvalidOperationException($"Type '{typeof(T).Name}' has no properties.");

            HeaderNames = new string[Properties.Length];
            for (var i = 0; i < Properties.Length; i++)
            {
                var attr = Properties[i].GetCustomAttribute<CsvHeaderAttribute>()
                    ?? throw new InvalidOperationException(
                        $"Property '{Properties[i].Name}' is missing {nameof(CsvHeaderAttribute)}.");
                HeaderNames[i] = attr.Header;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Since we are creating a benchmark, we should also implement a higher-performance version. We can improve performance by introducing a compiled delegate variant. It compiles Expression.Lambda<Func<T, string>> getters once per type, cache them and invoke the delegate array during export.

public static class GenericCsvExportCompiled
{
    public static string ExportToCsvCompiled<T>(this IEnumerable<T> items, string separator = ";") where T : class
    {
        ArgumentNullException.ThrowIfNull(items);

        var getters = TypeMetadataCache<T>.Getters;
        var lastIndex = getters.Length - 1;

        var result = new StringBuilder();
        result.Append(separator == ";"
            ? TypeMetadataCache<T>.JoinedDefaultHeaders
            : string.Join(separator, TypeMetadataCache<T>.Headers));
        result.AppendLine();

        foreach (var item in items)
        {
            for (var i = 0; i < getters.Length; i++)
            {
                result.Append(getters[i](item));

                if (i < lastIndex)
                    result.Append(separator);
            }
            result.AppendLine();
        }

        return result.ToString();
    }

    private static class TypeMetadataCache<T> where T : class
    {
        public static readonly string[] Headers;
        public static readonly string JoinedDefaultHeaders;
        public static readonly Func<T, string>[] Getters;

        static TypeMetadataCache()
        {
            var properties = typeof(T).GetProperties();
            Headers = new string[properties.Length];
            Getters = new Func<T, string>[properties.Length];

            for (var i = 0; i < properties.Length; i++)
            {
                var attr = properties[i].GetCustomAttribute<CsvHeaderAttribute>()
                    ?? throw new InvalidOperationException(
                        $"Property '{properties[i].Name}' on type '{typeof(T).Name}' is missing {nameof(CsvHeaderAttribute)}.");

                Headers[i] = attr.Header;
                Getters[i] = BuildGetter(properties[i]);
            }

            JoinedDefaultHeaders = string.Join(";", Headers);
        }

        private static Func<T, string> BuildGetter(PropertyInfo property)
        {
            var param = Expression.Parameter(typeof(T), "item");
            var propAccess = Expression.Property(param, property);
            var propType = property.PropertyType;

            Expression body;
            if (propType == typeof(string))
            {
                body = propAccess;
            }
            else if (propType == typeof(float) || propType == typeof(double))
            {
                body = Expression.Call(
                    propAccess,
                    propType.GetMethod(nameof(IFormattable.ToString), [typeof(string), typeof(IFormatProvider)])!,
                    Expression.Constant(null, typeof(string)),
                    Expression.Constant(CultureInfo.InvariantCulture, typeof(IFormatProvider)));
            }
            else
            {
                body = Expression.Call(
                    Expression.Convert(propAccess, typeof(IFormattable)),
                    typeof(IFormattable).GetMethod(nameof(IFormattable.ToString))!,
                    Expression.Constant(null, typeof(string)),
                    Expression.Constant(CultureInfo.InvariantCulture));
            }

            return Expression.Lambda<Func<T, string>>(body, param).Compile();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

BuildGetter uses the System.Linq.Expressions API to construct an expression tree that represents a property read, then calls .Compile() to emit a JIT-compiled delegate. The result is functionally equivalent to a hand-written Func<T, string> getter, there is no runtime reflection in the export loop, only a direct delegate invocation.

Caching metadata and compiled delegates per type is essential for performance. Using a generic static cache TypeMetadataCache<T> ensures that reflection and compilation occur only once per type and the static constructor guarantees thread-safe initialization. The trade-off is increased memory usage proportional to the number of distinct types and their property count.

To determine which properties should be exported a CsvHeaderAttribute is used.

[AttributeUsage(AttributeTargets.Property)]
public sealed class CsvHeaderAttribute : Attribute
{
    public string Header { get; } 

    public CsvHeaderAttribute(string header)
    {
        Header = header;
    }
}
Enter fullscreen mode Exit fullscreen mode

Hardcoded Baseline (Optimal Performance)

The hardcoded implementation serves as the theoretical performance ceiling, the best result achievable without any abstraction overhead.

To establish an optimal performance baseline, a hand-written static export class is provided for each type.

public static class CsvSmallItemExport
{
    private const string DefaultHeaders =
        "Item1;Item2;Item3;Item4;Item5;Item6;Item7";

    public static string ExportToCsvFast(this IEnumerable<CustomSmallItem> items, string separator = ";")
    {
        ArgumentNullException.ThrowIfNull(items);

        var result = new StringBuilder();

        result.AppendLine(separator == ";" ? DefaultHeaders : string.Join(separator,
            "Item1", "Item2", "Item3", "Item4", "Item5", "Item6", "Item7"));

        foreach (var item in items)
        {
            result.AppendByProperty(item, separator);
        }

        return result.ToString();
    }

    private static void AppendByProperty(this StringBuilder sb, CustomSmallItem item, string separator)
    {
        sb.Append(item.Item1).Append(separator);
        sb.Append(item.Item2).Append(separator);
        sb.Append(item.Item3.ToString(null, CultureInfo.InvariantCulture)).Append(separator);
        sb.Append(item.Item4.ToString(null, CultureInfo.InvariantCulture)).Append(separator);
        sb.Append(item.Item5.ToString(null, CultureInfo.InvariantCulture)).Append(separator);
        sb.Append(item.Item6.ToString(null, CultureInfo.InvariantCulture)).Append(separator);
        sb.Append(item.Item7);
        sb.AppendLine();
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: Another possible approach is to use a source generator, which can eliminate manual maintenance of creating this algorithm for every new type to export. However, this increases complexity and makes debugging more difficult. This article does not cover that approach, as it would likely produce similar performance to this approach.

Types to Test

In this benchmark, we also compare three different types with various numbers of properties:

  • CustomLargeItem with 32 properties
  • CustomItem with 16 properties
  • CustomSmallItem with 7 properties

Each type follows the same structure:

public sealed class CustomSmallItem
{
    [CsvHeader("Item1")]
    public int Item1 { get; init; }
    [CsvHeader("Item2")]
    public long Item2 { get; init; }
    [CsvHeader("Item3")]
    public DateTime Item3 { get; init; }
    [CsvHeader("Item4")]
    public TimeSpan Item4 { get; init; }
    [CsvHeader("Item5")]
    public double Item5 { get; init; }
    [CsvHeader("Item6")]
    public float Item6 { get; init; }
    [CsvHeader("Item7")]
    public string Item7 { get; init; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

Benchmark Code

The benchmark iterates over pre-generated collections of objects and exports them to CSV using different strategies:

[SimpleJob(RuntimeMoniker.Net10_0)]
[Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.Declared)]
[MemoryDiagnoser]
public class GenericExportBenchmark
{
    public IEnumerable<GenericExportBenchmarkData> Data()
    {
        yield return new GenericExportBenchmarkData(100);
        yield return new GenericExportBenchmarkData(500);
        yield return new GenericExportBenchmarkData(1000);
        yield return new GenericExportBenchmarkData(5000);
    }

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string CustomSmallItem(GenericExportBenchmarkData data)
        => data.SmallItems.ExportToCsv();

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string CustomItem(GenericExportBenchmarkData data)
        => data.Items.ExportToCsv();

    [Benchmark]
    [ArgumentsSource(nameof(Data))]
    public string CustomLargeItem(GenericExportBenchmarkData data)
        => data.LargeItems.ExportToCsv();

    ...
}
Enter fullscreen mode Exit fullscreen mode

Data for every property is generated using Bogus library which is a simple fake data generator for .NET.

Source Solution

Results

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
Intel Core i5-6400 CPU 2.70GHz (Skylake), 1 CPU, 4 logical and 4 physical cores
.NET SDK 10.0.201
  [Host]    : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3
  .NET 10.0 : .NET 10.0.5 (10.0.5, 10.0.526.15411), X64 RyuJIT x86-64-v3

Job=.NET 10.0  Runtime=.NET 10.0
Enter fullscreen mode Exit fullscreen mode
Method data Mean Allocated
CustomSmallItem 100 items 68.83 us 105.88 KB
CustomItem 100 items 166.72 us 205.24 KB
CustomLargeItem 100 items 481.90 us 401.48 KB
CustomSmallItemCompiled 100 items 50.78 us 91.93 KB
CustomItemCompiled 100 items 121.60 us 174.75 KB
CustomLargeItemCompiled 100 items 375.39 us 340.32 KB
CustomSmallItemFast 100 items 51.58 us 81.21 KB
CustomItemFast 100 items 113.66 us 153.5 KB
CustomLargeItemFast 100 items 338.45 us 297.58 KB
CustomSmallItem 500 items 497.87 us 459.59 KB
CustomItem 500 items 1,213.61 us 968.66 KB
CustomLargeItem 500 items 2,464.34 us 1941.58 KB
CustomSmallItemCompiled 500 items 424.42 us 389.41 KB
CustomItemCompiled 500 items 906.13 us 815.42 KB
CustomLargeItemCompiled 500 items 1,899.67 us 1637.65 KB
CustomSmallItemFast 500 items 382.09 us 335.19 KB
CustomItemFast 500 items 898.54 us 708.2 KB
CustomLargeItemFast 500 items 1,813.68 us 1422.41 KB
CustomSmallItem 1000 items 1,039.64 us 914.11 KB
CustomItem 1000 items 2,464.72 us 1941.5 KB
CustomLargeItem 1000 items 5,006.79 us 3871.34 KB
CustomSmallItemCompiled 1000 items 838.18 us 772.81 KB
CustomItemCompiled 1000 items 1,949.39 us 1635.9 KB
CustomLargeItemCompiled 1000 items 3,868.59 us 3261.73 KB
CustomSmallItemFast 1000 items 816.94 us 665.88 KB
CustomItemFast 1000 items 1,848.17 us 1422.42 KB
CustomLargeItemFast 1000 items 3,659.25 us 2832.84 KB
CustomSmallItem 5000 items 5,656.15 us 4513.63 KB
CustomItem 5000 items 14,005.05 us 9663.42 KB
CustomLargeItem 5000 items 29,519.88 us 19291.66 KB
CustomSmallItemCompiled 5000 items 4,714.09 us 3809.93 KB
CustomItemCompiled 5000 items 10,215.20 us 8140.42 KB
CustomLargeItemCompiled 5000 items 21,732.32 us 16245.08 KB
CustomSmallItemFast 5000 items 4,595.25 us 3274.34 KB
CustomItemFast 5000 items 9,867.36 us 7069.1 KB
CustomLargeItemFast 5000 items 21,885.77 us 14104.86 KB

Let’s focus on the data = 500 scenario, which can be considered a representative average for typical export workloads.

The fastest approach completes in approximately 898 µs for a medium-sized class with 16 properties, allocating ~708 KB. In comparison, the unoptimized reflection-based solution takes around 1 213 µs and allocates ~968 KB, making it ~30–40% slower and more memory-intensive.

The optimized reflection version performs noticeably better, completing in approximately ~906 µs and allocating ~815 KB. This brings its performance much closer to the hardcoded implementation, making it a reasonable trade-off between flexibility and efficiency.

The performance difference mainly comes from PropertyInfo.GetValue, which adds reflection overhead (method dispatch, type checks, and boxing for value types).

In the compiled version, this call is removed. Property access is turned into a precompiled delegate, so values are read directly without reflection at runtime.

However, most value types are still boxed in the fallback path by converting them to IFormattable. Only string, float and double avoid boxing.

So the speedup comes mostly from removing PropertyInfo.GetValue, not from eliminating boxing. Adding direct handling for more value types would further close the gap to the hardcoded version.

The results also show that allocation cost grows linearly with dataset size, indicating that memory pressure not just CPU can become a limiting factor in large exports.

A large part of the execution time and memory usage does not come from reflection itself, but from how the CSV string is built.

StringBuilder grows its internal buffer as data is added, which causes extra allocations. At the end, ToString() creates the final string and copies all collected data into it, which is also expensive. Additional allocations come from converting values to strings using IFormattable.ToString(null, CultureInfo.InvariantCulture).

This is visible in the Fast baseline, which uses no reflection but still allocates around ~665 KB for 1000 small items. This shows that most of the cost comes from string building and formatting, while reflection only adds the extra overhead above this baseline.

Note: These benchmarks measure in-memory string generation only. In real-world scenarios, I/O operations (e.g., writing to disk or network) typically add significant execution time.

Summary

The results show that compiled reflection significantly reduces the overhead of naive reflection by ~30–40%, bringing it close to the performance of a fully hardcoded solution.

The remaining gap is generally 2–10% depending on type size and dataset is relatively small and often not worth the loss of flexibility.

In most real-world scenarios, compiled delegates provide the best balance between maintainability and performance.

The key takeaway is that reflection itself is not inherently problematic, it becomes a bottleneck when used in hot paths or over large collections. For data export scenarios involving large collections, replacing repeated reflective access with compiled delegates provides a substantial performance improvement while retaining flexibility.

Top comments (0)