C# Interpolated String Handlers for Fast Logging
This article explores interpolated string handlers in C#, a feature introduced in C# 10 that revolutionizes how the runtime processes interpolated strings. You will learn the mechanics behind these handlers, the performance pitfalls of traditional string interpolation in logging, and how the handler pattern eliminates memory allocations and CPU overhead by short-circuiting formatting when log messages are disabled.
The Problem with Traditional String Interpolation
Before C# 10, passing an interpolated string to a logging method incurred an unavoidable performance penalty:
logger.LogDebug($"User {userId} completed transaction {transactionId} at {DateTime.UtcNow}");Under the hood, the C# compiler translated this call to
string.Format, creating a fully allocated string on the
managed heap regardless of whether the Debug log level was
enabled. Furthermore, value types like int or
DateTime were boxed into object instances.
Developers frequently had to wrap logging calls in conditional checks to
avoid wasting resources:
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug($"User {userId} completed transaction {transactionId} at {DateTime.UtcNow}");
}While effective, this pattern led to repetitive, boilerplate code that was easy to forget.
What Are Interpolated String Handlers?
An interpolated string handler is a custom type marked with the
[InterpolatedStringHandler] attribute that defines how an
interpolated string is parsed and processed. Instead of invoking
string.Format, the compiler lowers the interpolated string
into a series of calls directly on the handler:
- The handler’s constructor is called to initialize buffer sizes and inspect state.
- The compiler calls
AppendLiteralfor the static parts of the string. - The compiler calls
AppendFormattedfor each interpolated expression.
By acting as the direct recipient of each string segment, the handler
controls formatting, writes to reusable memory buffers (like
Span<char>), and avoids intermediate string
allocations.
How Handlers Optimize Logging Performance
The most significant performance benefit for logging comes from
conditional construction using an out bool isEnabled
parameter in the handler's constructor.
When a handler uses this pattern:
[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
private readonly bool _enabled;
public LogInterpolatedStringHandler(int literalLength, int formattedCount, ILogger logger, LogLevel level, out bool isEnabled)
{
_enabled = logger.IsEnabled(level);
isEnabled = _enabled;
}
public void AppendLiteral(string s) { /* ... */ }
public void AppendFormatted<T>(T value) { /* ... */ }
}The C# compiler checks the isEnabled flag immediately
after calling the constructor. If isEnabled evaluates to
false, the compiler skips all subsequent
AppendLiteral and AppendFormatted method
calls.
This mechanism provides critical optimizations:
- No String Allocation: When logging is disabled for a given level, no string is ever allocated in memory.
- No Argument Evaluation: Expressions inside the interpolation (such as method calls or property accessors) are never executed if the handler determines the message is inactive.
- Zero Boxing: Values passed into
AppendFormatted<T>are generic, preventing the boxing of value types like integers, booleans, and structs. - Clean Syntax: Developers can write natural,
interpolated strings without wrapping every statement in
IsEnabled()guard checks.
Standard Logging in Modern .NET
Starting with .NET 6, Microsoft updated the standard
ILogger extension methods to accept generated interpolated
string handlers. For example:
logger.LogDebug($"Processed record {record.Id} in {stopwatch.ElapsedMilliseconds}ms");If the logger's minimum level is set to Information, the
debug message's handler initializes, sets isEnabled to
false, and the program immediately moves to the next line
of code without evaluating record.Id, formatting the
elapsed time, or allocating a string on the heap.