AlgoMaster Logo

String Interpolation

Last Updated: May 22, 2026

High Priority
8 min read

String interpolation is the modern way to build a string from a template and a set of values. You write the template inline, drop expressions directly into the spots where values belong, and the compiler stitches everything together. It replaces the old string.Format(...) and + concatenation styles for nearly every real piece of C# code written today.

Basic Syntax

The interpolation syntax is a string literal with a $ in front of it. Inside the string, anything between { and } is treated as a C# expression, evaluated at runtime, converted to a string, and spliced into the result.

Three things are happening. The $ tells the compiler "this is an interpolated string." The {customer} and {itemCount} placeholders get replaced by the values of those variables. The {total:C} placeholder also formats the value as currency before splicing it in.

This feature was added in C# 6 (released with Visual Studio 2015), and it's been the default style for string building ever since. The older equivalents still work but read worse:

All three produce the same output, but the interpolated form is the only one where the template reads in the same order as the final text. Concatenation forces you to think about quoting and ToString calls; string.Format forces you to count positional indices. The $ form keeps everything inline.

A diagram of how an interpolated string is parsed helps clarify what the compiler does for you:

The template is split into alternating literal chunks and "holes." Each hole is an expression. The compiler emits code that evaluates the expressions, formats each result, and assembles the final string from the literal pieces and the formatted hole values.

A single interpolated string in flat code is cheap. The same interpolated string inside a tight loop allocates a fresh string on every iteration. For hot paths that build text in a loop, use StringBuilder (covered in lesson 04) and call AppendFormat or build the parts directly.

Expressions Inside Braces

A hole isn't limited to a bare variable name. Anything that evaluates to a value is fair game: properties, method calls, arithmetic, ternary expressions, null-conditional access, even nested interpolations. The compiler calls ToString() on whatever the expression produces.

A few practical examples from a checkout flow:

Walk through each line. {price * quantity:C} does arithmetic, then applies a format. {couponCode?.Length ?? 0} uses the null-conditional ?. to avoid a NullReferenceException, then falls back to 0 if the result is null. {(quantity > 0 ? "in cart" : "empty")} uses a ternary; the outer parentheses are needed because the colon inside the ternary would otherwise be mistaken for the start of a format specifier.

The last point is easy to miss. Inside a hole, the colon : is parsed as "everything after me is a format string." When the expression contains its own colon (a ternary or a generic type literal), wrap the expression in parentheses to tell the compiler "the colon is not mine."

The commented-out line won't compile because the compiler sees : and starts expecting a format string. Parentheses around the ternary fix it.

Method calls work the same way as any other expression:

Nested interpolation is allowed, though it's easy to overdo. The inner string needs its own $:

The outer template has one hole. Inside that hole is a ternary, and each branch is itself an interpolated string. The compiler handles the layers, but readers find deeply nested interpolation hard to scan. Two levels is usually the practical limit; beyond that, build the parts in regular variables first.

Format Specifiers (Brief)

Anything after a colon inside a hole is a format string passed to the value's ToString(format, ...) method. Format strings deserve their own lesson (the _String Formatting_ lesson covers the full catalog), but a small handful show up so often in e-commerce code that it's worth seeing them now.

A quick tour of what each one means in everyday terms:

SpecifierWhat it doesExample output
:CCurrency with the current culture's symbol and rounding rules$89.95
:N2Number with thousand separators and two decimal places1,299.50
:F2Fixed-point with two decimal places, no thousand separator1299.50
:PPercent (multiplies by 100 and appends %)8.25 % or 8.25% depending on culture
:XHexadecimal for integer types, uppercaseFF
:yyyy-MM-ddA custom date format string2025-11-28

A few cautions before the _String Formatting_ lesson covers the full set. The currency, number, and percent specifiers all depend on the current culture for separators and symbols. The :C specifier on a US-culture machine prints $; on a German-culture machine it prints with a comma as the decimal separator. The :P specifier with 0.0825 prints 8.25% because it multiplies by 100 automatically, so don't pass a value that's already a percentage. Date format strings are case-sensitive: MM is month, mm is minutes; dd is day-of-month, DD is not a valid token.

Format specifiers do real work. :C reads culture settings; :N2 does grouping and rounding; date formats walk a parse pattern. None of this matters for ordinary code, but when formatting millions of values in a loop, cache the formatted result or pre-build with StringBuilder.AppendFormat to avoid repeated culture lookups.

Alignment

After the value, you can request a minimum width with a comma and a number: {expr,width}. A positive width right-aligns the value in that many characters; a negative width left-aligns it. Combine width with a format string by putting the comma before the colon: {expr,width:format}.

A typical use is printing a small invoice that lines up cleanly:

Three columns, three different widths. {name,-12} pads the name out to twelve characters on the right (left-aligned). {qty,5} right-aligns the quantity in a five-character column. {price,12:C} right-aligns the price in twelve characters and formats it as currency.

A few rules worth remembering. The width is a minimum, not a maximum. If the value's text is longer than the requested width, the value is printed in full and the column overflows. Alignment uses spaces only; there's no built-in way to pad with zeros or another character through interpolation (use PadLeft/PadRight for that, or numeric format strings like :D5 for zero-padded integers).

The width was twelve, the name was twenty-nine characters, the column overflowed. No exception, no truncation, just plain text that's longer than the column you asked for.

Alignment adds a few padding characters to the output but doesn't change the algorithmic shape of anything. The cost is the same order as the rest of the interpolation: one allocation for the final string.

Escaping Braces

Since { and } are special inside an interpolated string, you need a way to put literal braces in your output. The escape is to double them: {{ produces a literal {, and }} produces a literal }.

Read this from left to right. {{order}} is two pairs of doubled braces wrapping the literal text order, so it prints as {order}. The second example has three braces in a row, then order, then three more: {{ is a literal {, then {order} is a hole that prints the value of order, then }} is a literal }. The result is {ORD-1042}.

The doubling rule is the same in string.Format and in interpolated strings, so the syntax transfers. Outside of interpolated strings, in a regular "..." literal, braces are not special and don't need escaping; the doubling rule only kicks in when the compiler is parsing holes.

For multi-line text or strings with lots of special characters, the verbatim form @$"..." and the C# 11 raw form $"""...""" can be combined with interpolation. Those are the focus of the _Verbatim & Raw String Literals_ lesson, but here's a quick taste:

The @ lets the string span multiple lines without \n, and the $ still enables interpolation. Either prefix can come first (@$"..." and $@"..." both work); the _Verbatim & Raw String Literals_ lesson covers the rules in detail.

FormattableString and Culture

By default, $"..." produces a string, formatted using the current thread's culture (CultureInfo.CurrentCulture). On most US-developer machines that means $ for currency, comma for thousand separator, dot for decimal. On a German machine the same code prints and swaps the separators. That's fine for output meant for a local user, but it's the wrong choice for anything that's stored or sent to another system: log files, JSON payloads, database keys, URLs.

When you need predictable formatting regardless of culture, capture the interpolated string as a FormattableString and call ToString(culture) on it, or use the static helpers on FormattableString.

Output (on a US-culture machine):

What's going on. The plain $"..." form is implicitly assigned to a string and uses the current culture. When you assign the same expression to a FormattableString, the compiler holds onto the template and the argument values without formatting yet. Then FormattableString.Invariant(fs) formats with the invariant culture (an English-like culture with no regional symbols, perfect for machine-readable output), and fs.ToString(someCulture) formats with whatever culture you pass.

A practical pattern from real codebases. When you're writing a customer-facing message, let the current culture handle it. When you're writing a value that another piece of software will read back, use the invariant culture:

The log line is identical no matter what culture the server happens to be running under. That's the point of the invariant form: stability across environments.

FormattableString is an abstract class that holds the format string, the argument values, and a culture-aware ToString method. The interface IFormattable is what individual values implement so that :C, :N2, and friends can ask "format yourself for this culture." Writing either of these types directly is rare, but knowing they exist explains why formatting can be delayed and a culture picked later.

Allocating a FormattableString and then calling ToString(culture) is slightly more work than a plain interpolation, because the framework keeps the arguments boxed and the format string around until the output is requested. For one-off invariant formatting it's invisible. For high-throughput logging, the raw string form with a deliberate CultureInfo.InvariantCulture argument to ToString on each value, or pre-built StringBuilder logic, is faster.

In modern .NET (6 and later), the compiler also lowers $"..." into calls on DefaultInterpolatedStringHandler, a struct that builds the result without intermediate allocations for most cases. The lowering is invisible; the compiler handles it. The practical effect is that idiomatic interpolation is fast enough for almost all production code, with deeper thought needed only in measurably hot paths.