String Interpolation · How it works
7 min readHow it works
Syntax
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.
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):Ccurrency,N2number with 2 decimals,Ppercent,yyyy-MM-ddfor dates,X8hex,D5zero-padded, and so on.
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"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:
A:
var json = $$"""
{
"id": {{order.Id}},
"customer": "{{order.CustomerName}}",
"tags": []
}
""";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.
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)
$"…" 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.
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.
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.
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
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.
$"{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.
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.
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
$"{x}" and x.ToString() for a null reference?A: A null hole produces an empty string; x.ToString() on null throws NullReferenceException.
$"{a ? "x" : "y"}" not compile?A: The : is parsed as the start of a format string. Wrap the conditional in parentheses: $"{(a ? "x" : "y")}".
$ 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.
+ 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.