Zum Hauptinhalt springen
Echtzeit-Radar & Feeds
Alle RSS Feeds ➔
👥 Community & Social
Windows Tipps & SecurityGrafikkarte vor Überhitzung schützen: So geht’s(25.09.2026 um 08:00 Uhr)
••••••••••
Windows Tipps & SecurityGrafikkarte vor Überhitzung schützen: So geht’s(25.09.2026 um 08:00 Uhr)
••••••••••
Intelligence View
⚡ tsecurity.de Intelligence

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 a…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

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);






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);
}









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.









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









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);
}






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 { ... }






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);






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);









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);
}






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









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.

2. Cyber Threat Intelligence & Forensik

CTI Threat Relationship Graph3 Knoten / 2 Relationen
CVE / Incident Software MITRE ATT&CK CWE Weakness IoC
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Benchmarked, Ways, Allocates, Nothing · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

💬 Kommentare werden geladen…
Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-100746 | A vulnerability was found in coollabsio Coolify up to 4.1.0. This affec…
Advisory →
tsecurity.de Icon
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag