Logging Performance: String Interpolation vs Structured Logging vs LoggerMessage


Quick disclaimer: this is a post about logging in C#. I know. Logging sits in the same excitement tier as reading the terms of service. It’s the corner of the codebase nobody wants to own and everyone wants to ignore. But people get it wrong constantly, in code reviews, in production, in tutorials that teach the wrong way as the default. So here we are, talking about it, because the boring stuff everyone gets wrong is exactly the stuff worth writing about.

Logging sits on hot paths everywhere. Every request, every state change, every error. You’d think “it’s just a log line, how slow can it be?”

Answer: depending on how you write it, embarrassingly slow. The kind of slow where the cheapest-looking line in your codebase quietly becomes one of the most expensive.

The Setup

The benchmark below uses a logger configured at Warning level. So Information calls get skipped, filtered out before anything hits the sink. Cheap, right? Not quite.

But here’s the thing: even though the log message never gets written, the code leading up to that decision still runs. And that’s where the cost hides.

To make the comparison fair, each approach gets tested twice. Once at Information (where the logger skips it), and once at Warning (where the message actually gets written). The Warning calls are the control group. They show you what the real work costs. The Information calls show you what you’re paying for nothing.

The Three Approaches

1. String Interpolation

// Disabled (Information, skipped by the logger):
logger.LogInformation($"User {userId} performed {action}");

// Enabled control (Warning, actually logs):
logger.LogWarning($"User {userId} performed {action}");

This is what everyone writes. Feels natural. The $ prefix tells the C# compiler to build an interpolated string handler and construct the final string before the method is even called.

That’s the kicker. The string assembly happens at the call site, not inside the logger. By the time LogInformation runs and checks IsEnabled, the string already exists. The allocation already happened. The logger can throw it away, but you can’t unspend the work.

When the logger is disabled, you pay the full string construction cost for nothing. The formatted string "User 42 performed login" gets built, passed to the logger, and discarded.

When the logger is enabled, the string is already built, so the logger just writes it. Interpolation is actually slightly cheaper than structured logging here because the logger receives a finished string and doesn’t need to parse a template or format anything. But you’ve already paid the allocation regardless.

2. Structured Logging

// Disabled (Information, skipped by the logger):
logger.LogInformation("User {UserId} performed {Action}", userId, action);

// Enabled control (Warning, actually logs):
logger.LogWarning("User {UserId} performed {Action}", userId, action);

Better. The template string is static. Parameters are passed separately. The logger can check if it’s enabled before formatting the message.

But the cost hides in the method signature. LogInformation takes params object[]. Before the method body runs, the CLR has to:

  1. Allocate an object[] array to hold the arguments.
  2. Box userId from int to object. That’s a second allocation on the heap.
  3. Pass the array to the method.

All of this happens before IsEnabled is checked. The logger can short-circuit the formatting, but the array and the boxed integer are already on the heap. That’s the tax.

When the logger is disabled, you throw away the array and the boxed int for nothing. The template never gets parsed. But the allocations already happened.

When the logger is enabled, the logger parses {UserId} and {Action} out of the template (cached after the first call), formats the values into the output string, and writes it. More work than interpolation, but you get structured data out the other side.

3. LoggerMessage (Source Generator)

public static partial class LogMessages
{
    [LoggerMessage(
        EventId = 1,
        Level = LogLevel.Information,
        Message = "User {UserId} performed {Action}")]
    public static partial void UserAction(ILogger logger, int userId, string action);

    [LoggerMessage(
        EventId = 2,
        Level = LogLevel.Warning,
        Message = "User {UserId} performed {Action}")]
    public static partial void UserActionWarning(ILogger logger, int userId, string action);
}

// Call sites:
LogMessages.UserAction(logger, userId, action);         // Disabled
LogMessages.UserActionWarning(logger, userId, action);   // Enabled control

This is the magic one. The [LoggerMessage] attribute triggers a source generator at compile time. It generates the entire logging implementation as a strongly typed method.

The generated method takes int userId and string action directly. No params object[]. No boxing. The int stays an int all the way through. The first thing the generated body does is check logger.IsEnabled(LogLevel.Information). If it returns false, the method returns immediately. Nothing allocated. Nothing parsed. Nothing formatted.

When the logger is disabled, the call is nearly free. An IsEnabled check and a return. Zero allocations.

When the logger is enabled, the source-generated code formats the message directly from the typed parameters. No object[] array. No boxing. Fewer allocations than structured logging, same structured output. This is the best of both worlds.

Beyond Performance: Queryability

There’s another reason to care beyond performance.

When you use string interpolation, your log provider sees a flat string: "User 42 performed login". It has no idea what the pieces mean. You can’t query “all logins by user 42” without regex or grep.

Structured logging sends key-value pairs. Your log provider receives UserId=42, Action=login as structured data. You can query WHERE UserId = 42 directly in Seq, Datadog, Grafana Loki, whatever you’re using.

LoggerMessage gives you the same structured benefits, just with zero runtime overhead.

Even if perf doesn’t matter to you (it should), structured logging makes your logs infinitely more useful.

Live Benchmark

The benchmark below runs entirely in your browser, .NET 10 compiled to WebAssembly, no server round-trip. It hammers all six tests (three approaches, each disabled and enabled) with 100,000 iterations each, 10,000 of those warmup.

Click the button to run it. Give it a few seconds to pull down the .NET runtime first, it’s about 2MB compressed. Your browser is about to compile and run .NET. That’s still weird to me too.

The Results

You’ll see two tables in the terminal above. The first covers the disabled calls (Information level, where the logger skips the sink). The second covers the enabled controls (Warning level, where the message actually gets written).

A quick caveat on the numbers. These came off my machine, from one run, in a browser tab. Yours will be different. Hardware varies, the WASM runtime varies, whatever else your laptop is busy doing varies. The exact digits don’t matter. The order of magnitude does. Interpolation will still cost you hundreds of nanoseconds you didn’t need to spend. LoggerMessage will still be cheap. The story holds even when the numbers move.

When the Logger Is Disabled (Information)

This is where the differences are starkest. The logger throws everything away, but you still pay for the work leading up to that decision.

String interpolation is the worst offender. It builds the string every single time, even when the logger is disabled. Expect around 1 microsecond (that’s 1,000 nanoseconds) per call, with ~80 bytes allocated per call. All wasted.

Structured logging is roughly twice as fast (~500ns), but still allocates memory. The object[] array and the boxed int are created at the call site before IsEnabled is checked. The logger skips the formatting, but the allocations are already on the heap.

LoggerMessage is blazing fast. Around 80-90 nanoseconds per call. That’s not a number your brain knows what to do with, so here’s one that lands: you could call it roughly 1.8 million times in the time it takes you to blink. And it allocates zero bytes. The source generator’s IsEnabled check happens before any work, so when the logger is disabled, the call returns immediately.

The takeaway: when the logger is disabled, LoggerMessage is roughly 13x faster than string interpolation and allocates nothing. Structured logging sits in the middle, still paying a cost it can’t get back.

When the Logger Is Enabled (Warning)

Now we’re paying for real work. All three approaches actually write something here, so the differences shrink.

String interpolation is slightly faster than structured logging when enabled. The string is already built at the call site, so the logger just writes it. No template parsing, no formatting step. But the allocation already happened, and you still get a flat string your log provider can’t query.

Structured logging does more work when enabled. The logger parses the template (cached after the first call), formats the parameters, and writes the structured output. A few more allocations than interpolation, but you get queryable data.

LoggerMessage does the same work as structured logging but with fewer allocations. No object[] array. No boxing. The source-generated code formats directly from the typed parameters. Same structured output, lower memory footprint.

Expect the enabled costs to be dominated by I/O and sink work, not by the call overhead. The differences between approaches shrink when the logger is actually writing. The big differences show up when it’s not.

The Verdict

Default to structured logging everywhere. It’s clean, queryable, and fast enough for the vast majority of code. Most of your codebase should be using it. Just don’t interpolate.

LoggerMessage is your escape hatch for hot paths. High-throughput services, inner loops, the spots where every nanosecond and every allocation counts. Yeah, there’s boilerplate. That’s the cost of squeezing out perf.

String interpolation? Just don’t. There’s no scenario where it’s the right call. You’re paying for a string you might never write, and your log provider gets a flat line it can’t do anything with. Both other approaches beat it on every axis.