String Interpolation · How it works

7 min read
Mid-level10 min read
Rapid overview

How it works

Syntax

Q: What is string interpolation?

A: A way to build a string by embedding expressions directly in a string literal. You prefix the literal with $ and put each expression in braces; the expression is evaluated and its string form is inserted.

var name = "Ada";
var items = 3;
var s = $"Hello {name}, you have {items} item{(items == 1 ? "" : "s")}.";
// "Hello Ada, you have 3 items."

It was added in C# 6 as a readable replacement for string.Format("Hello {0}", name), whose numbered placeholders are easy to get out of sync with the arguments.

Q: What is an "interpolated string literal", and how is it different from "string interpolation"?

A: The interpolated string literal is the token you write, $"…{expr}…": literal text parts plus interpolation holes (also called interpolations). String interpolation is the process of evaluating the holes and combining them with the text. In spec terms, $"Total: {sum:C}" is an interpolated string expression with one literal part ("Total: ") and one hole (sum with format C). Terminology matters in interviews only so far as you can name the parts: literal text, hole, alignment, format string.

  • Alignment is a minimum width: positive right-aligns, negative left-aligns, padded with spaces.
  • Format is passed to the value's IFormattable.ToString(format, provider): C currency, N2 number with 2 decimals, P percent, yyyy-MM-dd for dates, X8 hex, D5 zero-padded, and so on.
Q: How do alignment and format strings work in a hole?

A: The full hole syntax is {expression,alignment:format}.

foreach (var (item, price) in lines)
    Console.WriteLine($"{item,-12}|{price,10:C2}");
// Coffee      |     £3.50
// Sandwich    |     £6.25

$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}"   // ISO-like timestamp
$"{0.256:P1}"                               // "25.6 %" (culture-dependent)
$"{255:X4}"                                 // "00FF"
Q: How do you put a literal brace or a conditional expression in an interpolated string?

A: Double the brace: $"{{ \"id\": {id} }}" produces { "id": 42 }. A conditional (ternary) expression must be wrapped in parentheses, because a bare : would be read as the start of a format string: $"{(ok ? "yes" : "no")}".


Variants

  • Verbatim $@"…" (or @$"…" since C# 8): backslashes are literal and the string can span lines; a quote is written "". Common for Windows paths and regex: $@"C:\logs\{date:yyyyMMdd}.txt".
  • Raw string literals (C# 11) start and end with three or more quotes """. No escaping at all; content can contain " and \ freely, and indentation up to the closing quotes is removed. With interpolation, the number of $ signs sets how many braces open a hole: $"""…{x}…""" uses single braces; $$"""…{{x}}…""" uses double braces, so single braces are literal. That makes JSON templates readable:
Q: What are verbatim and raw interpolated strings?

A:

var json = $$"""
    {
      "id": {{order.Id}},
      "customer": "{{order.CustomerName}}",
      "tags": []
    }
    """;
Q: Can an interpolated string be a const?

A: Since C# 10, yes, if every hole is itself a constant string: const string Base = "/api"; const string Orders = $"{Base}/orders";. Numeric constants are not allowed in holes of a constant, because their formatting depends on culture at runtime. This is useful for route templates and attribute arguments.

Q: What changed with newlines in holes in C# 11?

A: The expression inside a hole of a non-verbatim interpolated string may now span multiple lines, so you can write a switch expression or a LINQ query inside a hole. Readability usually argues for pulling it into a local first.


What the compiler generates (memory level)

Q: How was $"…" compiled before C# 10, and what did it cost?

A: Into string.Format(format, args) (or string.Concat when every hole was a string with no format). string.Format takes object arguments, so every value-type hole (int, decimal, DateTime, Guid) was boxed onto the heap, an object[] was allocated when there were more than three arguments, and the format string was parsed at runtime on every call.

Q: How is it compiled since C# 10 / .NET 6?

A: When the target is string, the compiler lowers it to calls on DefaultInterpolatedStringHandler, a ref struct:

// You write
string s = $"Order {id} total {total:C}";

// Roughly what the compiler emits
var h = new DefaultInterpolatedStringHandler(literalLength: 13, formattedCount: 2);
h.AppendLiteral("Order ");
h.AppendFormatted(id);              // generic AppendFormatted<int>: no boxing
h.AppendLiteral(" total ");
h.AppendFormatted(total, "C");
string s = h.ToStringAndClear();    // one final string allocation

The handler rents a char buffer from ArrayPool<char>.Shared, sized from the literal length and hole count, and grows it if needed. Values that implement ISpanFormattable (all primitive numeric types, DateTime, Guid…) format straight into that buffer with no intermediate string. The result: no boxing, no object[], no runtime format-string parsing, and typically a single allocation for the final string, often several times faster than string.Format.

Q: Where does the resulting string live, and can you avoid even that allocation?

A: A string is an immutable heap object, so the final result is always one heap allocation. To avoid it, write into a buffer you already own:

Span<char> buffer = stackalloc char[64];
if (buffer.TryWrite($"{x},{y}", out int written))    // MemoryExtensions.TryWrite
    Send(buffer[..written]);                          // no string allocated

sb.Append($"{name}: {value}");   // StringBuilder overload appends directly, no temporary string

string.Create(CultureInfo.InvariantCulture, stackalloc char[128], $"…") is the equivalent when you do need a string but want an initial stack buffer and a specific culture.

Q: What is an interpolated string handler?

A: A type marked [InterpolatedStringHandler] that the compiler targets instead of building a string. An API accepts it as a parameter, and the compiler generates AppendLiteral / AppendFormatted calls into it. A handler constructor can take an out bool shouldAppend; when false, none of the holes are evaluated or formatted. Debug.Assert(condition, $"…") uses this so the message is only built when the assertion fails, and logging libraries can use it to skip work when a level is disabled.


FormattableString, culture and SQL

Q: What happens when an interpolated string is assigned to FormattableString?

A: The compiler does not produce a string. It calls FormattableStringFactory.Create(format, args), giving an object that keeps the composite format ("Order {0} total {1:C}") and the argument array separately. The receiver can then decide how to format, or not format at all. FormattableString.Invariant($"…") formats with the invariant culture; EF Core uses the separation to turn arguments into SQL parameters.

Q: Why is $"{price}" a culture bug waiting to happen?

A: Holes are formatted with CultureInfo.CurrentCulture. On a machine or request running under de-DE, $"{1.5}" is "1,5" and $"{date}" uses German date order. That breaks file formats, URLs, cache keys, JSON built by hand and anything parsed later. For machine-readable text use string.Create(CultureInfo.InvariantCulture, $"…") (.NET 6+) or FormattableString.Invariant($"…"), or explicit ToString(CultureInfo.InvariantCulture). Culture-aware formatting is right only for text shown to a user.

Q: How does EF Core use interpolation to prevent SQL injection, and how can it go wrong?

A: FromSql / FromSqlInterpolated / ExecuteSql take a FormattableString, so each hole becomes a DbParameter:

// Safe: WHERE Name = @p0
db.Users.FromSql($"SELECT * FROM Users WHERE Name = {name}");

// SQL INJECTION: the string is built first, then run raw
db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Name = '{name}'");

// Also injection: the interpolation happens in a local of type string
var sql = $"SELECT * FROM Users WHERE Name = '{name}'";
db.Users.FromSqlRaw(sql);

The difference is whether the compiler sees the target type FormattableString at the call; once the value becomes a string, the parameters are gone.

Q: Why should you not interpolate in ILogger calls?

A: _logger.LogInformation($"User {userId} logged in") builds the string even when the level is disabled, and it destroys structured logging: the log sink receives only the final text, not a UserId property to filter and aggregate on. Use a message template with named placeholders: _logger.LogInformation("User {UserId} logged in", userId);, or source-generated [LoggerMessage] methods for hot paths. Analyzer CA2254 flags this.


Common interview gotchas

Q: What is the difference between $"{x}" and x.ToString() for a null reference?

A: A null hole produces an empty string; x.ToString() on null throws NullReferenceException.

Q: Why does $"{a ? "x" : "y"}" not compile?

A: The : is parsed as the start of a format string. Wrap the conditional in parentheses: $"{(a ? "x" : "y")}".

Q: How many $ do you need to write { "a": {value} } literally with one hole?

A: Use $$"""{ "a": {{value}} }""": two $ means holes use double braces, so the single braces of the JSON are literal text.

Q: Is string concatenation with + or interpolation faster?

A: For a few parts they compile to similar code (string.Concat for all-string operands). With value types or formatting, C# 10+ interpolation is usually faster than + (which calls ToString() on each operand, creating intermediate strings) and much faster than string.Format. In loops that build large text, use StringBuilder, and append interpolated strings directly into it.