String interpolation in C# 14: what $"..." compiles to, interpolated string handlers and their performance, alignment and format specifiers, culture rules, raw interpolated literals, and the SQL-injection and structured-logging traps.
$"Hello {name}" is compiled, not parsed at run time. With a string target, C# 10+ uses DefaultInterpolatedStringHandler, which formats each hole straight into a pooled buffer without boxing, then allocates the final string once. Holes take alignment and format ({total,10:C}), and formatting uses the current culture, so for machine-readable text I use string.Create(CultureInfo.InvariantCulture, $"..."). Assigned to FormattableString, it keeps the format and arguments separate, which is how EF Core's FromSql($"...") turns holes into SQL parameters. Raw $$""" strings help with JSON. Two traps: interpolating into raw SQL causes SQL injection, and interpolating into log templates destroys structured logging.
String interpolation ($"...") is syntax that the compiler lowers to the most efficient formatting code available for the target type. Since C# 10 that usually means an interpolated string handler rather than string.Format. The senior parts are: knowing that the target type changes the meaning (string, FormattableString, a custom handler), that it uses the current culture, and where it is dangerous โ SQL and logging.
| Target | Lowered to |
|---|---|
string (C# 10+) |
DefaultInterpolatedStringHandler โ pooled buffer, generic AppendFormatted<T>, one final allocation |
| All constant parts (C# 10) | A compile-time const string |
FormattableString / IFormattable |
FormattableStringFactory.Create(format, args) โ keeps the format and arguments separately |
| A parameter typed as a custom handler | That handler (e.g. StringBuilder.AppendInterpolatedStringHandler, Debug.Assert's handler, logging handlers) |
| Older compilers | string.Format(...) or string.Concat(...) |
A handler is a type marked [InterpolatedStringHandler] with AppendLiteral and AppendFormatted methods. Key benefits:
AppendFormatted<T>(T value) is generic and uses ISpanFormattable.TryFormat to write directly into a buffer.char buffer.out bool shouldAppend. If false, the arguments are never evaluated. Debug.Assert(condition, $"...") uses this so the message is only built when the assert fails.StringBuilder.Append($"...") and Span<char>.TryWrite($"...") write directly into their destination.FormattableStringFormattableString fs = $"Order {id} at {when:d}";
string invariant = FormattableString.Invariant(fs); // or fs.ToString(CultureInfo.InvariantCulture)
object?[] args = fs.GetArguments(); // [id, when]
string format = fs.Format; // "Order {0} at {1:d}"
Because it preserves arguments separately, APIs can treat them safely โ the most important example is EF Core's FromSql($"...")/ExecuteSql($"..."), which turn each argument into a SQL parameter. In .NET 6+ you can also use string.Create(CultureInfo.InvariantCulture, $"...") to format invariantly without FormattableString's allocations.
var json = $$"""
{
"id": {{order.Id}},
"status": "{{order.Status}}"
}
""";
The number of $ signs sets how many braces open an interpolation hole, so single braces stay literal โ perfect for JSON, CSS or templates. Indentation up to the closing """ is stripped. (For real JSON, prefer a serialiser; this is great for test fixtures and prompts.)
Interpolation into string formats using CultureInfo.CurrentCulture. On a server, the current culture comes from the OS/container settings or request localisation middleware. So $"{price}" can produce 1234.5, 1,234.5 is not added (no grouping by default), but the decimal separator differs โ 1234,5 under de-DE โ and dates follow local formats (dd/MM/yyyy for en-AU, MM/dd/yyyy for en-US).
For machine-readable output (CSV, file names, cache keys, URLs, JSON written by hand, logs parsed by tools), use the invariant culture: string.Create(CultureInfo.InvariantCulture, $"..."), FormattableString.Invariant(...), or explicit ToString("O", CultureInfo.InvariantCulture) for dates.
SQL injection.
// DANGEROUS: string interpolation creates the full SQL text
db.Database.ExecuteSqlRaw($"DELETE FROM Orders WHERE CustomerId = '{customerId}'");
// SAFE: FormattableString overload parameterises every hole
db.Database.ExecuteSql($"DELETE FROM Orders WHERE CustomerId = {customerId}");
The two lines look almost identical; the difference is the method's parameter type (string vs FormattableString). Analysers EF1002 and CA2100 catch many cases. Raw ADO.NET and Dapper need explicit parameters.
Structured logging.
_logger.LogInformation($"Exported {count} rows for {customerId}"); // BAD
_logger.LogInformation("Exported {Count} rows for {CustomerId}", count, customerId); // GOOD
The interpolated version formats the string even when the log level is disabled, destroys the message template (every message is unique, so Seq / Application Insights / Elastic cannot group them), and loses the structured properties you would query on. Use message templates or [LoggerMessage] source generators. Also avoid interpolating personal data into log messages โ a Privacy Act concern.
Other risky spots: HTML (XSS โ use Razor's encoding), shell commands (Process.Start arguments โ use ArgumentList), file paths (path traversal โ validate and use Path.Combine + checks), and URLs (use Uri.EscapeDataString).
In a reporting API, a developer "tidied up" FromSqlInterpolated($"... WHERE Region = {region}") to FromSqlRaw($"... WHERE Region = '{region}'") while adding dynamic sorting. A penetration test found that region was user-controlled and the endpoint was injectable. We reverted to the FormattableString overload, used an allow-list for sort columns (identifiers cannot be parameters), enabled EF1002 as an error, and added a security test with ' OR 1=1 -- payloads.
Checks modern C# knowledge plus two security and operability traps that matter in production.
Green flags
FromSql (safe) from FromSqlRaw with interpolation (unsafe).Red flags
$"" inside logger.LogInformation.string.Format with no other differences.string s = $"Total for {name}: {amount:C2}";
becomes:
var handler = new DefaultInterpolatedStringHandler(literalLength: 12, formattedCount: 2);
handler.AppendLiteral("Total for ");
handler.AppendFormatted(name);
handler.AppendLiteral(": ");
handler.AppendFormatted(amount, "C2");
string s = handler.ToStringAndClear();
ArrayPool<char>.Shared (or uses a caller-provided Span<char>).AppendFormatted<T>: if T is ISpanFormattable (all primitives, DateTime, Guid, decimal...), calls TryFormat into the remaining buffer; grows by renting a bigger array if needed. Otherwise falls back to IFormattable.ToString or ToString().{x,10}) and format specifiers are passed through.ToStringAndClear creates the final string and returns the rented array to the pool.If all holes are constant strings, C# 10 makes the whole expression a constant: const string Url = $"{Base}/orders";.
InterpolatedStringHandlerArgumentA method can pass other parameters into the handler's constructor:
public void Append(IFormatProvider? provider,
[InterpolatedStringHandlerArgument("", "provider")] ref AppendInterpolatedStringHandler handler);
That is how sb.Append(CultureInfo.InvariantCulture, $"...") formats with a specific culture, and how logging-style handlers receive the logger and level so they can decide shouldAppend = logger.IsEnabled(level) before evaluating any argument expressions.
[InterpolatedStringHandler]
public ref struct GuardHandler
{
private DefaultInterpolatedStringHandler _inner;
public GuardHandler(int literalLength, int formattedCount, bool condition, out bool shouldAppend)
{
shouldAppend = !condition; // only build the message when the check FAILS
_inner = shouldAppend ? new(literalLength, formattedCount) : default;
}
public void AppendLiteral(string s) => _inner.AppendLiteral(s);
public void AppendFormatted<T>(T value) => _inner.AppendFormatted(value);
public string ToStringAndClear() => _inner.ToStringAndClear();
}
When shouldAppend is false, the compiler skips all Append* calls โ including evaluating expensive argument expressions such as {order.ToJson()}.
CurrentCulture vs CurrentUICulture vs invariantCurrentCulture controls formatting and parsing of numbers, dates and currency. Interpolation and ToString() use it.CurrentUICulture controls resource lookup (which .resx translation is shown).UseRequestLocalization sets both per request from the query string, cookie or Accept-Language header. Without it, the server's process culture is used.DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1), where all cultures behave like invariant โ en-AU currency formatting silently becomes ยค.Currency example: $"{19.95m:C}" gives $19.95 for en-AU, 19,95 โฌ for de-DE, and ยค19.95 in invariant culture. Australian apps typically set en-AU explicitly for user-facing formatting and invariant for data.
ILogger message templates ("Exported {Count} rows") are parsed once and cached. The provider gets the template and the argument values separately, so:
Count as a numeric property you can query (Count > 10000).LoggerExtensions still allocates the params object[] โ which is why [LoggerMessage] source-generated methods (strongly typed, level-checked, no boxing) are best for hot paths.Interpolating into the message produces a unique string per call: no grouping, no properties, formatting cost even when disabled, and log volume surprises.
FormattableStringFromSql(FormattableString sql) reads sql.Format ("... WHERE Region = {0}") and sql.GetArguments(), replaces each placeholder with a parameter name (@p0), and creates DbParameters with the argument values. Because values never become part of the SQL text, injection is impossible for those holes. Identifiers (table/column names, ORDER BY columns) cannot be parameters โ they need allow-listing, not interpolation.
string and then FormattableString, and compare the lowered code.CultureInfo.CurrentCulture = new CultureInfo("de-DE") and print $"{1234.5m}"; then use string.Create(CultureInfo.InvariantCulture, ...).LogTo(Console.WriteLine) and compare SQL from FromSqlRaw($"...") vs FromSql($"...").using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Strings;
public sealed class Order { public int Id { get; set; } public string Region { get; set; } = ""; public decimal Total { get; set; } }
public sealed class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public static class Interpolation
{
public static void Demo()
{
int id = 42;
decimal total = 1234.5m;
// Alignment and format specifiers
Console.WriteLine($"|{id,6}|{total,12:C2}|");
// Culture-safe machine-readable output
double lat = -33.8688, lon = 151.2093;
string key = string.Create(CultureInfo.InvariantCulture, $"{lat},{lon}");
Console.WriteLine(key); // -33.8688,151.2093 in any culture
// Raw interpolated JSON template
string json = $$"""{ "id": {{id}}, "total": {{total.ToString(CultureInfo.InvariantCulture)}} }""";
Console.WriteLine(json);
// Format into a stack buffer: no heap allocation
Span<char> buffer = stackalloc char[32];
if (buffer.TryWrite(CultureInfo.InvariantCulture, $"ORD-{id:D6}", out int written))
Console.WriteLine(buffer[..written].ToString()); // ORD-000042
}
// Safe SQL: FromSql turns holes into parameters
public static Task<List<Order>> ByRegionAsync(ShopContext db, string region, CancellationToken ct)
=> db.Orders.FromSql($"SELECT * FROM Orders WHERE Region = {region}").ToListAsync(ct);
// Structured logging: template + arguments, not interpolation
public static void LogShipped(ILogger logger, int orderId)
=> logger.LogInformation("Order {OrderId} shipped", orderId);
}
### 1. Interpolating values into SQL
**What happens:** `FromSqlRaw($"... WHERE Name = '{name}'")`, `new SqlCommand($"...{id}")` or Dapper queries built with `$`.
**Why it is wrong:** values become part of the SQL text โ SQL injection, plus no plan reuse.
**Do instead:** use `FromSql`/`ExecuteSql` with `FormattableString`, or explicit parameters; allow-list identifiers.
### 2. Interpolating log messages
**What happens:** `_logger.LogInformation($"User {userId} logged in")`.
**Why it is wrong:** loses structured properties and template grouping, formats even when the level is disabled, and often leaks personal data.
**Do instead:** use message templates or `[LoggerMessage]` methods.
### 3. Forgetting that interpolation uses the current culture
**What happens:** CSV lines, cache keys, file names or URLs built with `$"{amount}"` or `$"{date}"`.
**Why it is wrong:** output changes between servers, containers and requests (decimal commas, date orders), breaking parsers and cache hits.
**Do instead:** use `string.Create(CultureInfo.InvariantCulture, $"...")` or explicit format strings like `"O"` for dates.
### 4. Assuming `FormattableString` is always safe
**What happens:** passing a `FormattableString` to an API that just calls `ToString()` on it.
**Why it is wrong:** safety comes from the API parameterising arguments; a plain string conversion is as unsafe as normal interpolation.
**Do instead:** check the API's contract; only parameterising APIs (like EF Core's) are safe.
### 5. Accidentally converting to `string` and losing parameterisation
**What happens:** `var sql = $"... {id}"; db.Database.ExecuteSql(sql);` doesn't compile, so someone switches to `ExecuteSqlRaw(sql)`.
**Why it is wrong:** assigning to `var` makes it a `string`, and the raw API turns it into injectable SQL.
**Do instead:** pass the interpolated string directly to the `FormattableString` overload, or declare `FormattableString sql = $"...";`.
### 6. Using interpolation to build HTML, shell commands or URLs
**What happens:** `$""`, `Process.Start("cmd", $"/c del {file}")`, `$"https://api/x?q={term}"`.
**Why it is wrong:** XSS, command injection and broken or injectable URLs.
**Do instead:** Razor encoding, `ProcessStartInfo.ArgumentList`, `Uri.EscapeDataString` / `QueryHelpers.AddQueryString`.
### 7. Expensive expressions in disabled diagnostics
**What happens:** `Debug.WriteLine($"{Serialize(order)}")` or logging large objects in interpolated strings.
**Why it is wrong:** the expression is evaluated even when output is discarded (unless the API uses a conditional handler).
**Do instead:** use APIs with conditional handlers or check `IsEnabled` first.
### 8. Hand-building JSON with raw interpolated strings in production code
**What happens:** `$$"""{ "name": "{{name}}" }"""` for API payloads.
**Why it is wrong:** values are not escaped; a quote or backslash in `name` produces invalid or injected JSON.
**Do instead:** use `System.Text.Json` serialisation; keep raw strings for fixtures and templates with trusted values.
### 9. Believing interpolation is slow
**What happens:** teams ban interpolation in favour of `string.Format` or `StringBuilder`.
**Why it is wrong:** since C# 10, interpolation into `string` is typically the fastest option for fixed formats.
**Do instead:** use interpolation freely for readability; measure before optimising.
### 10. Mixing up `CurrentCulture` and `CurrentUICulture`
**What happens:** setting `CurrentUICulture` to `en-AU` and expecting dates to format as `dd/MM/yyyy`.
**Why it is wrong:** formatting uses `CurrentCulture`; `CurrentUICulture` only affects resources.
**Do instead:** configure both via request localisation, and be explicit in machine formats.
Interpolation is now both readable and efficient, but seniors watch the target type, because that decides behaviour.
| Target | Behaviour | Risk |
|---|---|---|
string |
formatted immediately, current culture | culture bugs in machine text |
FormattableString |
format + args kept separate | allocation, but enables parameterisation |
| Custom handler | API decides whether to format | none; very efficient |
String passed to ...Raw SQL APIs |
already a string | SQL injection |
| Log template | already a string | lost structure, wasted work |
FromSqlRaw), CA1305 (specify IFormatProvider).Formatting turns values into text using a format string and a culture. Composite format items look like {0,10:N2}: the index, an optional alignment, and a format such as C, N2, P, X or a custom "0.00"; dates use d, o or custom "yyyy-MM-dd". Every overload uses CultureInfo.CurrentCulture unless you pass an IFormatProvider, so 1234.5 can print as 1,234.50 in en-AU and 1.234,50 in de-DE. My rule is the user's culture for display and CultureInfo.InvariantCulture for anything machines read or write, with matching culture on parse. In hot paths, TryFormat writes into a span without allocating.
I use StringBuilder when I'm appending an unknown or large number of pieces, usually in a loop, because += creates a new string and copies everything on every iteration. For a fixed number of pieces, a + b + c or interpolation compiles to a single string.Concat or handler call, which is just as fast. Internally StringBuilder keeps a chunked mutable buffer, so it grows without recopying old text, and ToString() allocates the final string once. Alternatives are string.Join for collections, string.Create when I know the exact length, and streaming to a writer for very large output.
How to tell it (STAR)
**S:** Before an IRAP assessment, a penetration test found SQL injection in our finance reporting endpoint [at company]. **T:** I owned the fix and making sure there were no other cases. **A:** I traced it to an interpolated FromSqlRaw, switched it and [n] similar calls to the parameterised FromSql API, validated region values against an allow-list, and made the analyser warning a build error. **R:** The retest passed and no further injection findings were raised in the assessment.
Last updated 30 Oct 2026.