Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 059 / 100

Strings: StringBuilder and interpolation for production

String concatenation in loops still hurts.

Expona Admin6 min read

Strings: StringBuilder and interpolation

String concatenation in loops still hurts—and I've seen it in production codebases more often than I'd like to admit. Modern .NET gives us several tools to handle strings efficiently, but choosing the right one depends on your workload. In this post, we'll explore string interpolation handlers that make $"" fast in modern .NET, when to reach for StringBuilder, how string.Create saves allocations, and why StringComparison.Ordinal should be your default.

Interpolation handlers make $"" fast in modern .NET

For years, string interpolation was syntactic sugar that got compiled to string.Format. That meant every interpolation created an array of objects and went through the format machinery—not terrible for a single interpolation, but expensive in hot paths.

Starting in .NET 6, interpolated string handlers changed the game. When you write:

csharp
string name = "Alice";
int count = 42;
string message = $"User {name} processed {count} items.";

The compiler now generates code that builds the string directly without intermediate allocations, if the handler supports it. The runtime team added handlers for common cases like logging and HTTP headers.

Here's what makes this practical: logging frameworks benefit immediately.

csharp
// .NET 6+ with interpolation handler
logger.LogInformation($"Order {orderId} created for {customerName} with total {total:C}");

// Without handler, this would allocate even if the log level was disabled
// Now it's lazy-evaluated and zero-allocation at disabled levels

The real win is that the interpolation is not evaluated until the logging framework decides it needs the string. For disabled log levels, no string is built at all.

But here's the catch: interpolation handlers work best for single operations. If you're building a string from dozens of pieces in a loop, interpolation handlers won't save you. That's where StringBuilder comes in.

StringBuilder for loops and large text

Strings in .NET are immutable. Every concatenation creates a new object. In a loop, this gets expensive fast:

csharp
// BAD: Creates a new string on every iteration
string result = "";
for (int i = 0; i < 10000; i++)
{
    result += $"Item {i}, ";
}

That loop creates 10,000 string objects. Your GC is now working overtime.

StringBuilder fixes this by maintaining a resizable buffer:

csharp
// GOOD: Single buffer, reused throughout
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    sb.Append("Item ").Append(i).Append(", ");
}
string result = sb.ToString();

StringBuilder starts with a 16-character buffer and doubles it as needed. By the end, you have one final string allocation when you call ToString().

I've used this pattern in CSV export features, where we're generating large reports. Here's a more realistic example:

csharp
public string GenerateCsvReport(List<Order> orders)
{
    var sb = new StringBuilder();
    sb.AppendLine("OrderId,CustomerName,Total,Status");
    
    foreach (var order in orders)
    {
        sb.Append(order.Id).Append(',')
          .Append(EscapeCsvField(order.CustomerName)).Append(',')
          .Append(order.Total).Append(',')
          .AppendLine(order.Status);
    }
    
    return sb.ToString();
}

Method chaining on StringBuilder keeps the code readable. The real performance gain shows when you're building strings with hundreds or thousands of rows.

One more tip: if you know the final size ahead of time, pass it to the constructor:

csharp
var sb = new StringBuilder(capacity: 50000);

This avoids resize allocations during the build.

string.Create for fixed-size formatting

StringBuilder is excellent, but it allocates a buffer, builds into it, and then creates the final string. For scenarios where you know the exact output size beforehand, string.Create lets you write directly into the final string's memory.

csharp
// Format a timestamp without intermediate allocations
public string FormatTimestamp(DateTime dt)
{
    return string.Create(19, dt, (span, state) =>
    {
        state.TryFormat(span, out _, "yyyy-MM-dd HH:mm:ss");
    });
}

Here, string.Create allocates a 19-character string and hands you a Span<char> to write into. The delegate receives the span and your state object (dt in this case). Once the delegate returns, the string is finalized.

This is faster than StringBuilder for fixed-size outputs because there's no intermediate buffer. I've used it in financial trading platforms where timestamp formatting happens millions of times per day:

csharp
// High-frequency trading log format: 2024-12-19 14:35:42.123456
public string FormatTradeTimestamp(DateTime dt)
{
    return string.Create(26, dt, (span, state) =>
    {
        var ticks = state.Ticks;
        state.ToString("yyyy-MM-dd HH:mm:ss", null).CopyTo(span);
        span[19] = '.';
        var microseconds = (ticks % 10_000_000) / 10;
        microseconds.TryFormat(span.Slice(20), out _, "D6");
    });
}

The downside: you must know the size upfront. For variable-length strings, StringBuilder is still your best bet.

Compare with StringComparison.Ordinal unless culture matters

String comparison is another performance trap that's easy to miss. When you call string.Equals(a, b) without specifying a comparison type, it defaults to StringComparison.CurrentCulture. This respects the machine's culture settings—which means it's slower and can give unexpected results across different systems.

csharp

// SLOW and culture-dependent
if (statusCode.Equals("success"))
{
    // This uses CurrentCulture
}

// FAST and consistent
if (statusCode.Equals("success", StringComparison.Ordinal))
{
    // Byte-by-byte comparison, same everywhere
}

In my experience, 99% of the time you want Ordinal comparison. You're comparing enum names, API keys, protocol strings, database IDs—things that are not human-readable text needing cultural sensitivity.

Here's a real scenario from a ride-hailing platform I worked on:

csharp
public class RideRequest
{
    public string Status { get; set; }
    
    public bool IsCompleted()
    {
        // Bad: Culture-aware, slower
        return Status.Equals("completed");
        
        // Good: Ordinal, fast, consistent across regions
        return Status.Equals("completed", StringComparison.Ordinal);
    }
    
    public bool IsActive()
    {
        // Also good: Use switch expressions (they use Ordinal by default)
        return Status switch
        {
            "requested" or "accepted" or "in_transit" => true,
            _ => false
        };
    }
}

When you do need culture-aware comparison (sorting user names in a report for a specific locale), use StringComparison.CurrentCultureIgnoreCase explicitly. But make it obvious in code.

Common mistakes

I see three mistakes repeatedly in production code:

  • String concatenation in loops. Using += or string interpolation inside a loop instead of StringBuilder. The performance cliff hits fast around 100+ iterations.
  • Forgetting to specify StringComparison. Leaving comparison culturally dependent when it shouldn't be, creating subtle bugs across regions or slower code than necessary.
  • Over-allocating StringBuilder capacity. Passing huge capacities "just in case" wastes memory. Profile first; only pre-allocate if you have metrics showing the benefit.

Key takeaways

  • Interpolation handlers in .NET 6+ make $"" zero-allocation in many cases; use them for single operations and logging.
  • StringBuilder is the go-to for loops and building large strings from many pieces.
  • Use string.Create when you know the exact output size and need to avoid intermediate buffers.
  • Always specify StringComparison.Ordinal unless you have a specific reason to use culture-aware comparison.
  • Profile before optimizing; string performance matters in hot paths, but most code paths are not hot.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles