DEV Community

JF Meyers
JF Meyers

Posted on

I Benchmarked 3 Ways to Log in .NET : One Allocates Nothing

Your API handles 2,000 requests per second. Each request logs 5 messages. That is 10,000 log calls per second — and if your log level is set to Warning, every single Debug and Information call is wasted work. The string gets built. The arguments get boxed. The GC collects the result. Nobody reads it.

I ran the benchmarks. The difference is not subtle.

Three ways to log the same thing

Here is the same log statement written three ways. All three produce identical output when the level is enabled.

// Style 1: string interpolation
logger.LogInformation($"Order {orderId} shipped to {country}");

// Style 2: message template (structured)
logger.LogInformation("Order {OrderId} shipped to {Country}", orderId, country);

// Style 3: [LoggerMessage] source generation
LogOrderShipped(orderId, country);

[LoggerMessage(Level = LogLevel.Information,
    Message = "Order {OrderId} shipped to {Country}")]
private partial void LogOrderShipped(Guid orderId, string country);
Enter fullscreen mode Exit fullscreen mode

Style 1 is what most developers write by default. Style 2 is what the docs recommend. Style 3 is what the runtime team actually uses internally.

The benchmark

I used BenchmarkDotNet to measure all three styles under two conditions: when the log level is enabled (the message reaches the sink) and when it is disabled (the message is filtered out before writing).

[MemoryDiagnoser]
[ShortRunJob]
public class LoggingBenchmarks
{
    private ILogger<LoggingBenchmarks> _enabledLogger = null!;
    private ILogger<LoggingBenchmarks> _disabledLogger = null!;
    private Guid _orderId;
    private string _country = null!;

    [GlobalSetup]
    public void Setup()
    {
        _orderId = Guid.NewGuid();
        _country = "Belgium";

        _enabledLogger = LoggerFactory
            .Create(b => b.SetMinimumLevel(LogLevel.Trace).AddFakeLogger())
            .CreateLogger<LoggingBenchmarks>();

        _disabledLogger = LoggerFactory
            .Create(b => b.SetMinimumLevel(LogLevel.Warning).AddFakeLogger())
            .CreateLogger<LoggingBenchmarks>();
    }

    [Benchmark(Baseline = true)]
    public void Interpolation_Enabled()
        => _enabledLogger.LogInformation($"Order {_orderId} shipped to {_country}");

    [Benchmark]
    public void Template_Enabled()
        => _enabledLogger.LogInformation("Order {OrderId} shipped to {Country}",
            _orderId, _country);

    [Benchmark]
    public void SourceGen_Enabled()
        => LogOrderShipped(_enabledLogger, _orderId, _country);

    [Benchmark]
    public void Interpolation_Disabled()
        => _disabledLogger.LogInformation($"Order {_orderId} shipped to {_country}");

    [Benchmark]
    public void Template_Disabled()
        => _disabledLogger.LogInformation("Order {OrderId} shipped to {Country}",
            _orderId, _country);

    [Benchmark]
    public void SourceGen_Disabled()
        => LogOrderShipped(_disabledLogger, _orderId, _country);

    [LoggerMessage(Level = LogLevel.Information,
        Message = "Order {OrderId} shipped to {Country}")]
    private static partial void LogOrderShipped(
        ILogger logger, Guid orderId, string country);
}
Enter fullscreen mode Exit fullscreen mode

Results (.NET 10, x64, RyuJIT)

Method Mean Allocated
Interpolation_Enabled ~320 ns 256 B
Template_Enabled ~280 ns 64 B
SourceGen_Enabled ~250 ns 0 B
Interpolation_Disabled ~95 ns 184 B
Template_Disabled ~35 ns 64 B
SourceGen_Disabled ~1.5 ns 0 B

Read that last row again. 1.5 nanoseconds. Zero bytes allocated. When the level is disabled, the source-generated method checks IsEnabled and returns immediately — no string formatting, no boxing, no array allocation. The interpolation version still burns 95 ns building a string that nobody will ever see.

At 10,000 calls/second with the level disabled, that is:

  • Interpolation: 1.8 MB/s of garbage for the GC to collect
  • Message template: 640 KB/s of garbage
  • Source generation: nothing

Why the difference exists

String interpolation ($"...")

The compiler transforms $"Order {orderId} shipped to {country}" into a string.Format call (or DefaultInterpolatedStringHandler on .NET 6+). Either way, the string is fully constructed before LogInformation is called. The logger has no chance to skip it.

// What the compiler actually generates (simplified)
var handler = new DefaultInterpolatedStringHandler(21, 2);
handler.AppendLiteral("Order ");
handler.AppendFormatted(orderId);
handler.AppendLiteral(" shipped to ");
handler.AppendFormatted(country);
logger.LogInformation(handler.ToStringAndClear()); // Already a string. Too late.
Enter fullscreen mode Exit fullscreen mode

Message template ("Order {OrderId}")

The ILogger extension methods accept params object?[] arguments. Value types like Guid get boxed. The object[] is allocated on every call. The level check happens inside the method — after the caller has already paid for the array.

// What you write
logger.LogInformation("Order {OrderId} shipped to {Country}", orderId, country);

// What happens at the call site
var args = new object[] { orderId, country }; // boxed Guid + array alloc
logger.Log(LogLevel.Information, "Order {OrderId} shipped to {Country}", args);
// IsEnabled check is INSIDE Log() — too late to avoid the allocation
Enter fullscreen mode Exit fullscreen mode

Source generation ([LoggerMessage])

The source generator emits a method that checks IsEnabled before touching any argument:

// What the source generator emits (simplified)
private static void LogOrderShipped(ILogger logger, Guid orderId, string country)
{
    if (!logger.IsEnabled(LogLevel.Information))
        return; // Zero work done

    logger.Log(
        LogLevel.Information,
        new EventId(0, nameof(LogOrderShipped)),
        new __LogOrderShippedStruct(orderId, country), // stack-allocated struct
        null,
        __LogOrderShippedStruct.Format);
}
Enter fullscreen mode Exit fullscreen mode

The arguments are passed to a generated struct — no heap allocation, no boxing. The format method is cached as a static delegate. When the level is disabled, the method returns in a single branch instruction.

Five things I got wrong the first time

When I migrated a codebase to [LoggerMessage], I hit every possible mistake. Saving you the trip:

1. Forgetting partial on the class

The method is partial, but so must be the containing class. The source generator needs to emit the implementation in a separate file.

// Compiler error: no suitable method found to override
public class OrderService { ... }

// Works
public partial class OrderService { ... }
Enter fullscreen mode Exit fullscreen mode

2. Using interpolation inside the message string

The Message property is a template, not a format string. Placeholders use {PascalCase} names that match the method parameters.

// Wrong — this is a constant string, not a template
[LoggerMessage(Level = LogLevel.Information,
    Message = $"Order {nameof(orderId)} processed")] // Compile error

// Right — named placeholders matching parameters
[LoggerMessage(Level = LogLevel.Information,
    Message = "Order {OrderId} processed")]
private partial void LogOrderProcessed(Guid orderId);
Enter fullscreen mode Exit fullscreen mode

3. Returning a value from a log method

[LoggerMessage] methods must return void. They are fire-and-forget by design.

4. Mismatching parameter names and placeholders

The parameter name orderId maps to the placeholder {OrderId} by convention (case-insensitive). If they do not match, the generator emits a warning and the value shows as (null) in output.

5. Forgetting the Exception parameter

If your message accompanies an error, pass the Exception as a parameter — do not .ToString() it into the message. The logging infrastructure preserves the full exception object, and sinks like Seq or Application Insights render it properly.

// Wrong — stack trace baked into a string, impossible to filter
[LoggerMessage(Level = LogLevel.Error,
    Message = "Import failed: {Error}")]
private partial void LogImportFailed(string error);
// Called as: LogImportFailed(ex.ToString()); 

// Right — exception is structured metadata
[LoggerMessage(Level = LogLevel.Error,
    Message = "Import failed for job {JobId}")]
private partial void LogImportFailed(Guid jobId, Exception exception);
Enter fullscreen mode Exit fullscreen mode

When to use each style

I am not saying you should grep-and-replace every logger.Log* call in your codebase tomorrow. Here is a practical decision framework:

Context Recommendation Why
Hot paths (>100 calls/s) [LoggerMessage] Zero allocation matters here
Library code (NuGet packages) [LoggerMessage] You do not control the consumer's log level
Application startup / shutdown Message template is fine Runs once, readability wins
Quick prototype / script Interpolation is fine You are not shipping this
Anywhere you log an Exception [LoggerMessage] Structured exception > .ToString()

The real decision point: if the code runs in a loop or on every request, use [LoggerMessage]. For everything else, message templates are a reasonable default.

Migration in 10 minutes

Here is a quick mechanical process for an existing class:

- public class PaymentService
+ public partial class PaymentService
  {
      private readonly ILogger<PaymentService> _logger;

      public async Task ChargeAsync(Guid paymentId, decimal amount)
      {
-         _logger.LogInformation($"Charging {amount:C} for payment {paymentId}");
+         LogCharging(paymentId, amount);
          // ...
-         _logger.LogError(ex, $"Payment {paymentId} failed");
+         LogChargeFailed(paymentId, ex);
      }
+
+     [LoggerMessage(Level = LogLevel.Information,
+         Message = "Charging {Amount} for payment {PaymentId}")]
+     private partial void LogCharging(Guid paymentId, decimal amount);
+
+     [LoggerMessage(Level = LogLevel.Error,
+         Message = "Payment {PaymentId} failed")]
+     private partial void LogChargeFailed(Guid paymentId, Exception exception);
  }
Enter fullscreen mode Exit fullscreen mode

Steps:

  1. Add partial to the class declaration
  2. For each logger.Log*() call, create a [LoggerMessage] partial method
  3. Replace the call site with the new method
  4. Build — the generator validates signatures at compile time

If you have a large codebase, the Microsoft.Extensions.Logging analyzer (SYSLIB1006-SYSLIB1015) flags common issues. Enable it in your .editorconfig:

[*.cs]
dotnet_diagnostic.SYSLIB1006.severity = warning
Enter fullscreen mode Exit fullscreen mode

The compliance bonus nobody talks about

Here is a side benefit that surprised me: [LoggerMessage] makes GDPR compliance audits easier.

With string interpolation, PII can hide anywhere: $"User {email} logged in" bakes the email into an opaque string. You cannot redact it downstream. Your only option is a regex-based log scrubber — fragile and always one edge case away from leaking data.

With [LoggerMessage], every parameter is explicitly named in the attribute. You can write a Roslyn analyzer that scans all [LoggerMessage] declarations and flags any placeholder named Email, Phone, IpAddress, or Token at build time. PII never reaches production logs because the compiler stops you.

I built exactly this for Granit, an open-source modular .NET framework I maintain. The analyzer (GRSEC011) flags PII-indicative parameter names, and architecture tests verify every [LoggerMessage] across 120+ packages. Zero PII in logs, enforced at compile time — not by policy documents that nobody reads.

TL;DR

  • String interpolation in logs allocates on every call, even when the level is disabled. At scale, that is measurable GC pressure.
  • Message templates avoid the string but still box value types into object[].
  • [LoggerMessage] source generation checks the level first, avoids all allocation, and preserves structured data.
  • The difference is 1.5 ns vs 95 ns when the level is disabled. At scale, that is free GC pressure you are handing back to your application.
  • Migration is mechanical: add partial, extract methods, build. The compiler does the rest.
  • Bonus: named parameters make PII audits trivial — a Roslyn analyzer can enforce data minimization at compile time.

I maintain Granit, an open-source modular .NET framework (200+ NuGet packages, Apache-2.0) with built-in GDPR compliance, isolated DbContexts, and source-generated everything. If you are building enterprise .NET apps and tired of reinventing the same plumbing, check it out.

Top comments (0)