Skip to content
Ian Griffiths By Ian Griffiths Technical Fellow I
C# 11.0 new features: newlines in string interpolation expressions

This third post in my series on new features in C# 11.0 describes the last of the enhancements for string literal handling. This is the simplest of the new string features.

Interpolated strings have been in C# for many years. They let us embed expressions in string literals:

Console.WriteLine($"The answer is {40 + 2}");

Before C# 11.0, you weren't allowed to split the embedded expressions over multiple lines. You couldn't write this, for example:

// Illegal before C# 11.0
Console.WriteLine($"The answer is {40
    +
    2}");

For the common use case of displaying simple expressions such as properties, e.g. "Name: {person.Name}" this was not a big problem. However, various language features added to C# since string interpolations first appeared have made it practical to express increasingly sophisticated computations in a single expression. For example, switch expressions (added in C# 8.0) can express quite complex logic, but they do not look good if you try to cram them onto a single line.

The Introduction to Rx.NET (v6.1) 3rd Edition (2025) Book, by Ian Griffiths & Lee Campbell, is now available to download for FREE.

C# 11.0 relaxes the restriction, allowing you to put newlines inside the braces delimiting an interpolated expression. Space inside these expressions do not make it into the resulting string, so in this example, the newlines are there purely for layout—the string value itself contains no new newlines:

Console.WriteLine($"{individual} {
    individual switch
    {
        "I" or "You" or "They" => "have",
        _ => "has",
    }} {
    individual switch
    {
        "I" => "given a confidential security briefing",
        "You" => "leaked",
        _ => "been charged under section 2a of the Official Secrets Act"
    }}.");

What's wrong with string.Format?

You might look at that and think that it would be even more readable if we just stuck with good old string.Format style string formatting, (an idiom that Console.WriteLine supports directly so we don't even have to call string.Format explicitly):

Console.WriteLine($"{0} {1} {2}.",
    individual,
    individual switch
    {
        "I" or "You" or "They" => "have",
        _ => "has",
    },
    individual switch
    {
        "I" => "given a confidential security briefing",
        "You" => "leaked",
        _ => "been charged under section 2a of the Official Secrets Act"
    }.");

And you might have a point. However, there are sometimes reasons to prefer string interpolation. For example, C# 10 brought in some performance enhancements to string interpolation. Consider this simple benchmark:

[MemoryDiagnoser]
public class Tests
{
    private int i;

    [Benchmark]
    public string WithStringInterpolation()
    {
        i += 1;
        return $"{i} * {i} = {i * i}.";
    }

    [Benchmark(Baseline = true)]
    public string WithStringFormat()
    {
        i += 1;
        return string.Format("{0} * {0} = {1}.",
            i,
            i * i);
    }
}

This generates the same string two ways: once with interpolation and once with string.Format. There's a non-trivial performance difference:

Method Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
WithStringInterpolation 90.73 ns 1.845 ns 2.817 ns 0.72 0.02 0.0114 96 B 0.67
WithStringFormat 124.15 ns 1.725 ns 1.614 ns 1.00 0.00 0.0172 144 B 1.00

Execution time and memory consumption are both significantly lower with string interpolation.

The difference can be more dramatic with types that participate directly in string interpolation handling. Take this benchmark which uses a Logger type that integrates with string interpolation:

[MemoryDiagnoser]
public class Tests2
{
    private int i;
    private Logger logger = new();

    [Benchmark]
    public void WithStringInterpolation()
    {
        i += 1;
        logger.Log(LogLevel.Trace, $"{i} * {i} = {i * i}.");
    }

    [Benchmark(Baseline = true)]
    public void WithStringFormat()
    {
        i += 1;
        logger.Log(LogLevel.Trace, string.Format("{0} * {0} = {1}.",
            i,
            i * i));
    }
}

The difference here is enormous:

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
WithStringInterpolation 3.595 ns 0.0943 ns 0.1009 ns 0.03 - - 0.00
WithStringFormat 123.362 ns 2.1480 ns 1.9042 ns 1.00 0.0172 144 B 1.00

Why such a huge difference? Well in this benchmark, I configured things so that trace-level logging is disabled, so neither of the benchmarks actually produces any log output. A little unfair, you might think, but in practice it's common to configure logging to be relatively quiet most of the time.

Programming C# 12 Book, by Ian Griffiths, published by O'Reilly Media, is now available to buy.

Because the Logger class I'm using here (shown below) integrates with string interpolation, it's able to completely avoid creating the string when it detects that it's not going to be needed.

I could have made the second benchmark's performance much closer to the first by adding an if statement to check the log level and skip the call to logger.Log when it's not required. But when using string interpolation with an interpolation-aware class, you don't need to do that—this technique offers a much more succinct way to achieve high performance when logging is disabled.

Here is the Logger type (which is based on the examples in https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) that the benchmark uses:

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

    public void Log(
        LogLevel level,
        [InterpolatedStringHandlerArgument("", "level")]
        LogInterpolatedStringHandler builder)
    {
        if (level < EnabledLevel) { return; }
        Console.WriteLine(builder.GetFormattedText());
    }

    public void Log(LogLevel level, string msg)
    {
        if (level < EnabledLevel) { return; }
        Console.WriteLine(msg);
    }
}

public enum LogLevel
{
    Off, Trace, Warning, Error, Critical,
}

When you pass an interpolated string, the first overload of Log gets used. The C# compiler generates code that interacts with this LogInterpolatedStringHandler, and avoids creating the string entirely when it's not required.

We're quite a long way from this blog's topic now, but for completeness, here's the handler:

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    StringBuilder builder;

    public LogInterpolatedStringHandler(
        int literalLength,
        int formattedCount,
        Logger logger,
        LogLevel level,
        out bool isEnabled)
    {
        isEnabled = level >= logger.EnabledLevel;
        builder = isEnabled ? new StringBuilder(literalLength) : default!;
    }

    public void AppendLiteral(string s) => builder.Append(s);

    public void AppendFormatted<T>(T t) => builder.Append(t);

    internal string GetFormattedText() => builder.ToString();
}

(Note: sadly most .NET logging frameworks don't seem to have taken advantage of this feature yet, so in practice we still need to clutter up our logging with if statements. It is slightly disappointing that over a year after C# 10.0 shipped, we still need to use sample code from the documentation to demonstrate the benefits—I didn't find anything in the .NET runtime libraries themselves using this mechanism that I could use to demonstrate the benefits of the string interpolation performance enhancements. To be fair, though, the structured logging code generator added in .NET 6.0 is equally efficient, and has some benefits over interpolated strings. .NET 7.0 did add some new methods to the System.Debug type that use custom string interpolation, such as certain overloads of WriteLineIf and Assert, but since those use the Conditional attribute to remove calls to such methods in non-DEBUG builds, there aren't going to be any performance benefits in release builds. It's baffling that the equivalent methods of the Trace class didn't get the same treatment when those typically are used in release builds.)

Anyway, most of that wasn't really C# 11.0 content—all this hyper-efficient interpolated string handling was added back in C# 10. However, it's relevant because it can give us a reason to prefer interpolated strings over string.Format. And at that point, C# 11.0's support for more flexible formatting of interpolated expressions becomes more interesting.

Conclusion

String interpolation is a convenient way to build up strings from data, and can sometimes offer better performance than the alternatives. Starting with C# 11.0, embedded expressions can contain newlines, which can improve the readability of our code.

FAQs

Why would you use string interpolation with newlines instead of string.Format, especially for complex expressions? C# 10 introduced significant performance optimizations for string interpolation that string.Format cannot match. Benchmarks show interpolation can be 28% faster with 33% less memory allocation for simple cases. More dramatically, interpolation-aware types (like custom loggers) can completely skip string creation when output isn't needed—a disabled logging call using interpolation takes ~3.6ns with zero allocation versus ~123ns and 144 bytes with string.Format. These optimizations make interpolation worthwhile even when expressions are complex enough to benefit from multiline formatting.
How do custom string interpolation handlers achieve near-zero cost for disabled logging scenarios? Custom handlers implement an [InterpolatedStringHandler] type with a constructor that receives context (like log level and logger instance) and sets an 'isEnabled' out parameter. When disabled, the handler short-circuits—AppendLiteral and AppendFormatted methods are never called, no StringBuilder is created, and no string is built. The compiler generates code that checks isEnabled before evaluating any interpolation expressions. This is fundamentally impossible with string.Format, which must construct the complete string before the logging method can decide whether to use it.
Do newlines inside interpolated string expressions appear in the final string output? No, whitespace (including newlines) inside the braces of an interpolated expression is purely for code layout and does not appear in the resulting string. This is different from newlines in raw string literals or verbatim strings, where whitespace does become part of the string value. The feature simply relaxes a restriction that previously required cramming complex expressions onto single lines.

Ian Griffiths

Technical Fellow I

Ian Griffiths

Ian has worked across an extraordinary breadth of computing - from embedded real-time systems and broadcast television to medical imaging and cloud-scale architectures. As Technical Fellow at endjin, he brings this deep cross-domain experience to bear on the hardest technical problems.

A 17-time Microsoft MVP in Developer Technologies, Ian is the author of O'Reilly's Programming C# 12.0 and one of the foremost authorities on the C# language and high-performance .NET development. He's a maintainer of Reactive Extensions for .NET, Reaqtor, and endjin's 50+ open source projects.

Ian has created Pluralsight courses on WPF fundamentals, WPF advanced topics, WPF v4, and the TPL, and has given over 20 talks at conferences worldwide. Technology brings him joy.