ILogger is the logging abstraction most ASP.NET Core code reaches for without a second thought, and for the vast majority of log statements in a typical app, that is perfectly fine. The trouble starts when a log call sits on a hot path, something firing thousands of times a second, where the small inefficiencies in how ILogger is typically called start to add up in ways that show up in a profiler.
The .NET 6 LoggerMessage source generator was built specifically to close that gap, giving you the performance of the fastest hand-written logging pattern without writing the boilerplate that pattern normally demands. Before getting to the generator itself, it is worth understanding exactly what it is optimizing away, since the reasoning explains why the generated code looks the way it does.
The Usual Ways to Call ILogger, and Where Each One Costs You
The simplest possible call looks like this, injecting ILogger<T> through the constructor and calling LogInformation directly.
public class TestController
{
private readonly ILogger<TestController> _logger;
public TestController(ILogger<TestController> logger)
{
_logger = logger;
}
[HttpGet("/")]
public string Get()
{
_logger.LogInformation("Writing hello world response");
return "Hello world!";
}
}
This is fine for a log statement that fires occasionally. The first thing worth ruling out immediately is string interpolation in the message itself. It is tempting because it reads cleanly, but it quietly breaks structured logging entirely.
// Don't do this
_logger.LogInformation($"Writing hello world response to {person}");
Three separate problems stack up here. Structured log sinks like Seq lose the ability to filter by the format value once it has been baked into a plain string. The message template itself stops being a stable identifier you can group identical log lines by. And worst for performance specifically, the string gets built and allocated immediately, before ILogger even gets a chance to check whether that log level is enabled, so you pay the allocation cost even for a message that ends up being filtered out entirely.
That last point generalizes beyond interpolation. Even the structured version, LogInformation(“… {Person}”, person), still allocates a message template and builds the format ahead of time unless you explicitly guard the call.
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Writing hello world response to {Person}", person);
}
The IsEnabled guard is a real fix, it skips the allocation entirely when the level is filtered out, but it is boilerplate you have to remember on every single log call, and the log level in the guard has to stay in sync with the log level in the call, which is one more place for a copy-paste mistake to creep in.
There is a subtler failure mode too. Update a message template to add a new placeholder, and forget to pass the matching argument, and this compiles fine but throws at runtime.
// Compiles, throws at runtime
_logger.LogInformation("Writing hello world response to {Person} because {Reason}", person);

Some IDEs catch this at edit time. Rider, for instance, flags the mismatch directly in the editor before you even build.

Not everyone is on an IDE that catches this, though, and relying on tooling to catch a class of bug that the compiler could catch instead is not a great place to be.
LoggerMessage.Define, the Manual Fast Path
Before the source generator existed, the accepted way to get genuinely fast, allocation-free logging was LoggerMessage.Define, creating a cached delegate once and reusing it on every call.
private static readonly Action<ILogger, Person, Exception?> _logHelloWorld =
LoggerMessage.Define<Person>(
logLevel: LogLevel.Information,
eventId: 0,
formatString: "Writing hello world response to {Person}");
[HttpGet("/")]
public string Get()
{
var person = new Person(123, "Joe Blogs");
_logHelloWorld(_logger, person, null);
return "Hello world!";
}
This gives you three real wins at once: the message template gets parsed exactly once, at startup, instead of on every call, the IsEnabled check is baked into the delegate automatically, and the compiler forces you to pass the right type and number of arguments to the delegate. The catch is obvious the moment you look at it, this is a lot of ceremony to write and maintain for every single log message in a codebase, which is precisely why almost nobody did it consistently before .NET 6.
What the Source Generator Actually Does
The [LoggerMessage] source generator’s entire job is generating that LoggerMessage.Define boilerplate for you, at compile time, from a partial method declaration.
public partial class TestController
{
[LoggerMessage(0, LogLevel.Information, "Writing hello world response to {Person}")]
partial void LogHelloWorld(Person person);
}
The class needs to be marked partial, and the method itself is a partial method declaration with no body, the source generator supplies the implementation in a separate generated file. Calling it looks exactly like calling any other method.
[HttpGet("/")]
public string Get()
{
var person = new Person(123, "Joe Blogs");
LogHelloWorld(person);
return "Hello world!";
}
What actually gets generated behind the scenes is close to identical to the hand-written LoggerMessage.Define version, just written for you.
partial class TestController
{
private static readonly Action<ILogger, Person, Exception?> __LogHelloWorldCallback
= LoggerMessage.Define<Person>(
LogLevel.Information,
new EventId(0, nameof(LogHelloWorld)),
"Writing hello world response to {Person}",
new LogDefineOptions() { SkipEnabledCheck = true });
partial void LogHelloWorld(Person person)
{
if (_logger.IsEnabled(LogLevel.Information))
{
__LogHelloWorldCallback(_logger, person, null);
}
}
}
The one difference from writing this by hand is that the generator explicitly hoists the IsEnabled check outside the cached delegate call, rather than relying on LoggerMessage.Define’s own internal check. It is a small detail, and does not change the performance story meaningfully, but it is worth knowing if you go looking at the generated code and wonder why it does not match the manual version line for line.
Two features worth knowing about beyond the basic case. First, message template checking happens at compile time now, not just at runtime, because the generator doubles as an analyzer. Reference a placeholder in the template string that has no matching method parameter and you get a build error instead of a runtime exception.
Error SYSLIB1014 : Template 'Reason' is not provided as argument to the logging method
Error CS0103 : The name 'Reason' does not exist in the current context
Second, the generator supports a dynamic log level, useful when the severity of a log line genuinely depends on runtime data rather than being fixed at the call site.
[LoggerMessage(Message = "Writing hello world response to {Person}")]
partial void LogHelloWorld(LogLevel logLevel, Person person);
There is a real trade-off worth knowing about here: dynamic log levels are not compatible with the plain LoggerMessage.Define fast path internally, the generator falls back to a more complex readonly struct implementing IReadOnlyList to carry the state, which reintroduces a small allocation on the enabled path. It is still considerably faster than the naive approaches, just not quite as free as the fixed-level case.
The Actual Numbers
Benchmarks were run with BenchmarkDotNet across six logging approaches, each tested twice, once with the log level enabled and once with it filtered out entirely.
Method Mean Allocated
InterpolatedEnabled 552.872 ns 664 B
InterpolatedDisabled 530.244 ns 664 B
DirectCallEnabled 98.459 ns 64 B
DirectCallDisabled 87.049 ns 64 B
IsEnabledCheckEnabled 107.838 ns 64 B
IsEnabledCheckDisabled 6.709 ns 0 B
LoggerMessageDefineEnabled 58.794 ns 0 B
LoggerMessageDefineDisabled 7.989 ns 0 B
SourceGeneratorEnabled 49.165 ns 0 B
SourceGeneratorDisabled 6.684 ns 0 B
SourceGeneratorDynamicLevelEnabled 46.289 ns 64 B
SourceGeneratorDynamicLevelDisabled 7.362 ns 0 B
A few things stand out. String interpolation is not just marginally worse, it is roughly an order of magnitude slower than the direct call, and it allocates the full 664 bytes whether the message actually gets logged or not, because the interpolated string gets built before ILogger ever sees it. If you have interpolated log messages anywhere in a hot path, that is the first thing to fix, before reaching for anything more sophisticated.
LoggerMessage.Define and the source generator land in essentially the same place, both around 50 nanoseconds when the log level is enabled and effectively free, no measurable allocation, when it is disabled. Given that the source generator gets you there without writing the delegate boilerplate by hand, there is no real reason left to write LoggerMessage.Define manually for new code once you are targeting .NET 6.
The dynamic log level variant is the one case that costs something extra, 64 bytes on the enabled path, coming from the struct wrapper needed to support a level decided at runtime. That is still a fraction of the interpolation cost, and if your log line genuinely needs a level that varies per call, it remains the right trade to make.
Where This Actually Matters
None of this is a reason to rewrite every LogInformation call in an existing codebase. For log statements that fire occasionally, the difference between 50 nanoseconds and 500 nanoseconds is not something any user will ever notice. Where this genuinely earns its keep is hot paths, middleware that runs on every request, tight loops processing large batches, anything logging at a rate high enough that per-call overhead compounds into a measurable share of total request time.
For new code on .NET 6 or later, reaching for the source generator by default costs you almost nothing, a partial class, a partial method, and an attribute, and it structurally rules out both the interpolation mistake and the mismatched-placeholder mistake at compile time rather than leaving them as runtime landmines. That combination of free performance and compile-time safety is a rare enough pairing that it is worth making the default habit, not just the hot-path exception.
Leave a Reply