📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

How does string interpolation work in C#? Explain interpolated string handlers, FormattableString, raw interpolated strings, which culture is used, and where interpolation is dangerous (SQL and logging).

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.

Mid-Level Theory Intermediate csharp formatting logging security string-interpolation strings

๐ŸŽฏ Key points

  • $"..." with a string target compiles to DefaultInterpolatedStringHandler (C# 10 / .NET 6): pooled buffer, no boxing, one final allocation
  • If every hole is a constant string, the result is a compile-time constant (const interpolated strings, C# 10)
  • Holes support alignment and format: {value,10:F2}; braces are escaped with {{ and }}
  • Interpolation uses the current culture; use string.Create(CultureInfo.InvariantCulture, $"...") or FormattableString.Invariant for machine-readable text
  • Assigned to FormattableString or IFormattable, the compiler captures the format and arguments instead of building a string
  • Custom handlers ([InterpolatedStringHandler]) let APIs skip formatting entirely, e.g. Debug.Assert messages and StringBuilder.Append
  • Raw interpolated strings ($$"""...""") (C# 11) let JSON braces stay literal; the number of $ signs sets how many braces open a hole
  • Never interpolate values into SQL strings or log message templates: use parameters (FromSql) and structured logging placeholders

โฑ 30-second answer

$"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.

๐Ÿ—ฃ Full answer (senior)

How I would answer this in an interview

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.

1. What the compiler generates

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(...)

2. Interpolated string handlers

A handler is a type marked [InterpolatedStringHandler] with AppendLiteral and AppendFormatted methods. Key benefits:

  • No boxing: AppendFormatted<T>(T value) is generic and uses ISpanFormattable.TryFormat to write directly into a buffer.
  • No intermediate strings: values are formatted into a pooled char buffer.
  • Conditional evaluation: a handler constructor can take an 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.
  • Libraries like StringBuilder.Append($"...") and Span<char>.TryWrite($"...") write directly into their destination.

3. FormattableString

FormattableString 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.

4. Raw interpolated strings (C# 11)

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.)

5. Which culture is used

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.

6. Where interpolation is dangerous

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).

7. A production example

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.

๐Ÿ” What the interviewer is checking

Checks modern C# knowledge plus two security and operability traps that matter in production.

Green flags

  • Explains interpolated string handlers and why they avoid boxing.
  • Knows interpolation uses the current culture and how to make it invariant.
  • Distinguishes FromSql (safe) from FromSqlRaw with interpolation (unsafe).
  • Uses structured logging templates.

Red flags

  • Builds SQL or JSON with interpolation.
  • Uses $"" inside logger.LogInformation.
  • Thinks interpolation is just string.Format with no other differences.

๐Ÿ“š Learn in depth

1. Lowering in detail

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();
  • The constructor estimates an initial buffer size from the literal length plus a guess per hole, rents from 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().
  • Alignment ({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";.

2. Handler argument passing: InterpolatedStringHandlerArgument

A 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.

3. Writing a custom handler (sketch)

[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()}.

4. Culture: CurrentCulture vs CurrentUICulture vs invariant

  • CurrentCulture controls formatting and parsing of numbers, dates and currency. Interpolation and ToString() use it.
  • CurrentUICulture controls resource lookup (which .resx translation is shown).
  • In ASP.NET Core, UseRequestLocalization sets both per request from the query string, cookie or Accept-Language header. Without it, the server's process culture is used.
  • Containers based on Alpine or chiseled images may run in globalization-invariant mode (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.

5. Why logging templates matter

ILogger message templates ("Exported {Count} rows") are parsed once and cached. The provider gets the template and the argument values separately, so:

  • Log sinks store Count as a numeric property you can query (Count > 10000).
  • Messages with the same template are grouped for alerting and dashboards.
  • If the level is disabled, 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.

6. How EF Core parameterises FormattableString

FromSql(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.

Try it yourself

  1. Paste an interpolated string into SharpLab, targeting string and then FormattableString, and compare the lowered code.
  2. Set CultureInfo.CurrentCulture = new CultureInfo("de-DE") and print $"{1234.5m}"; then use string.Create(CultureInfo.InvariantCulture, ...).
  3. Log with interpolation and with a template into a structured sink (Seq or the console JSON formatter) and compare the output.
  4. Run EF Core with LogTo(Console.WriteLine) and compare SQL from FromSqlRaw($"...") vs FromSql($"...").

๐Ÿ’ป Code

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);
}

โš  Common mistakes

### 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.

๐Ÿ› Senior insight: trade-offs

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
  • Add analysers to CI: CA2254 (log templates), EF1002 (EF Core warns about interpolated FromSqlRaw), CA1305 (specify IFormatProvider).
  • In ASP.NET Core, set request localisation deliberately so UI text matches the user, and keep invariant culture for everything else.
  • Raw string literals make tests with JSON or SQL fixtures far more readable.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: The candidate uses {value:C2} or mentions culture. Why: Format specifiers and culture inside holes are composite formatting rules.
    How to answer

    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.

  2. When: The candidate discusses allocation of interpolated strings. Why: Compares interpolation with StringBuilder and other builders.
    How to answer

    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.

๐Ÿ›  Real-world problems: how you handle them

SQL injection through an interpolated FromSqlRaw query
Situation
A reporting endpoint in an internal finance portal built a query with FromSqlRaw($"... WHERE Region = '{region}'"), where region came from the query string.
Signals
A penetration test before an IRAP assessment showed that a crafted region value returned all rows and could execute additional statements; WAF logs showed similar probes.
Investigation
Reviewed the code path from the controller to EF Core, enabled EF Core command logging to see the SQL text containing the injected value, and searched the codebase for FromSqlRaw and ExecuteSqlRaw with interpolation.
Resolution
Replaced all interpolated *Raw calls with FromSql/ExecuteSql (parameterised), added input validation for region against a FrozenSet of allowed values, and used a least-privilege database login.
Prevention
EF1002 and a custom analyser treated as errors, a security unit test with malicious inputs, and secure coding guidance in the PR template.

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.

โ†ฉ Usually asked after

More in Strings

Last updated 30 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me