【C#】文字列補間完全ガイド|書式指定・CultureInfo・C# 10補間ハンドラー・raw string・ILogger・SQL安全性まで

【C#】文字列補間($"…")の基本と活用例 C#

C# 6 で導入された文字列補間(Interpolated String, $"..."は、単に string.Format の糖衣構文ではなく、C# 10 でコンパイラが最適化する補間ハンドラー機構・C# 11 でraw string との連携など、現代の C# では最重要の文字列生成 API です。カジュアルに使える反面、CultureInfo 依存・構造化ログでの誤用・SQL インジェクションなど知っておくべき落とし穴も多く存在します。

本記事では書式指定子と幅指定・$@/@$・C# 10 の DefaultInterpolatedStringHandler 内部実装・C# 11 の raw string 連携・FormattableString による CultureInfo 制御・SQL / HTML 安全性・ILogger での誤用防止・パフォーマンス最適化・カスタム補間ハンドラーまで体系的に解説します。

スポンサーリンク

基本構文と式の埋め込み

変数・式・メソッド呼び出しをそのまま埋める
// 変数の埋め込み
string name = "Alice";
int age = 30;
string msg = $"Hello, {name}! You are {age} years old.";

// 計算式
int a = 3, b = 5;
Console.WriteLine($"{a} + {b} = {a + b}");       // 3 + 5 = 8

// メソッド呼び出し
Console.WriteLine($"Now: {DateTime.Now:HH:mm:ss}");

// 三項演算子(括弧で囲むか :?: を避ける記法がよい)
bool premium = true;
Console.WriteLine($"{(premium ? "プレミアム" : "通常")} ユーザー");

// プロパティ・インデクサ
var list = new[] { "A", "B", "C" };
Console.WriteLine($"1番目: {list[0]}, 件数: {list.Length}");

// null 合体演算子も OK
string? input = null;
Console.WriteLine($"入力: {input ?? "(未入力)"}");

// 中かっこ自体を出力するには {{ と }}
Console.WriteLine($"{{{name}}}");   // {Alice}

書式指定子(:)と幅指定(,)

書式指定子の主な使い方
decimal price = 1234.5m;
DateTime today = new(2026, 4, 16);

// 書式指定子は {式:フォーマット}
Console.WriteLine($"{price:C}");           // ¥1,234.50(通貨)
Console.WriteLine($"{price:C0}");          // ¥1,234(小数なし)
Console.WriteLine($"{price:N2}");          // 1,234.50(3桁区切り + 小数2桁)
Console.WriteLine($"{price:F2}");          // 1234.50(固定小数点)
Console.WriteLine($"{0.75:P1}");           // 75.0%(パーセント)
Console.WriteLine($"{255:X}");             // FF(16進大文字)
Console.WriteLine($"{255:X4}");            // 00FF
Console.WriteLine($"{255:D5}");            // 00255(整数を0埋め)
Console.WriteLine($"{12345:###,###}");     // 12,345(カスタム書式)

// 日時
Console.WriteLine($"{today:yyyy-MM-dd}");   // 2026-04-16
Console.WriteLine($"{today:yyyy年M月d日}"); // 2026年4月16日(日本語リテラル)
Console.WriteLine($"{today:o}");            // 2026-04-16T00:00:00.0000000(ISO 8601)

// TimeSpan
TimeSpan ts = TimeSpan.FromMinutes(75);
Console.WriteLine($"{ts:hh\\:mm}");       // 01:15(C# 文字列内で \\:  と書くと実値は \: でコロンをエスケープ)
幅指定(,)とアライメント
// {式,幅:書式} の形で幅を指定(正: 右寄せ、負: 左寄せ)
Console.WriteLine($"|{"Name",-10}|{"Price",8:C}|");
// 出力: |Name      |  ¥1,234|

// 表形式の出力
var items = new[]
{
    ("Apple",  150m),
    ("Banana", 80m),
    ("Cherry", 320m),
};
foreach (var (name, price) in items)
    Console.WriteLine($"{name,-10}{price,8:C}");
// Apple     ¥   150
// Banana    ¥    80
// Cherry    ¥   320

// 幅指定もコロン書式と併用
Console.WriteLine($"{12345.678,15:N2}");   // 右詰め幅15で "      12,345.68"
書式指定子 用途
C 通貨(現在カルチャ) {1234:C} → ¥1,234
N 数値 + 3桁区切り {1234:N2} → 1,234.00
F 固定小数点 {1.5:F3} → 1.500
P パーセント {0.75:P} → 75.00%
X / x 16進数(大/小) {255:X4} → 00FF
D 整数0埋め {5:D3} → 005
E 指数表記 {12345:E2} → 1.23E+004
G 汎用(既定) {12345:G} → 12345
yyyy-MM-dd 日付 {now:yyyy-MM-dd}

verbatim と raw string との組み合わせ

$@”…” と @$”…” の並べ順
string user = "Alice";

// $@"..." と @$"..." はどちらも同じ(C# 8+ で両順序OK)
string path1 = $@"C:\Users\{user}\Documents";
string path2 = @$"C:\Users\{user}\Documents";
// 両方とも: C:\Users\Alice\Documents

// verbatim なので \ はエスケープ不要、複数行リテラルもOK
string sql = $@"
    SELECT *
    FROM Users
    WHERE Name = '{user}'
        AND IsActive = 1";
// ⚠ この書き方は SQL インジェクションの温床 → 後述の FormattableString を参照

// 中かっこの扱いは通常の補間と同じ
Console.WriteLine($@"{{ {user} }}");  // { Alice }

// 改行: verbatim なので実際の改行がそのまま文字列に入る
Console.WriteLine($@"Hello,
World");
raw string + 補間(C# 11+)
string user = "Alice";
string city = "Tokyo";

// C# 11: """..."""(raw string literal)
// 補間する場合は先頭の $ を付ける。$ の数 = 補間識別子の {} の数
string json = $$"""
{
    "name": "{{user}}",
    "city": "{{city}}",
    "tags": ["vip", "active"]
}
""";

// $$ なので {{ }} が補間プレースホルダ、{ } はそのままリテラル
// → JSON のような「{} を多用する」構文でエスケープが劇的に減る

// インデント: 閉じ """ のインデントが基準になり、各行から差し引かれる
string html = $"""
    <div class="user">
      <span>{user}</span>
    </div>
    """;
// 出力 (先頭のインデント4スペースは削除):
// <div class="user">
//   <span>Alice</span>
// </div>

// 複数文字の補間識別子が必要なケース({{ } を文字として含む場合)
string example = $$$"""
{{name}} は文字として残り、{{{value}}} だけ補間される
""";
raw string + 補間は JSON・正規表現・SQL で強力
C# 11 の $$"""...""" は「{{」「}}」を補間識別子に変更できるため、JSON({ } が構造)や LaTeX、正規表現の {n,m} などで威力を発揮します。$ の数を増やせば「何文字連続の中括弧を補間として扱うか」を自由に調整できるため、どんな文字列もエスケープなしで表現できるようになります。

CultureInfo の制御 — ロケール依存を避ける

文字列補間は現在のカルチャでフォーマットされるため、サーバーのロケールによって小数点が「.」と「,」に変わるなどの落とし穴があります。API レスポンス・設定ファイル・DB に保存する文字列では必ずカルチャを明示してください。

FormattableString と Invariant
using System.Globalization;

double price = 1234.5;

// ① 通常の $"..." は現在カルチャ(CurrentCulture)でフォーマット
string localized = $"{price:N2}";
// ja-JP: "1,234.50"
// de-DE: "1.234,50" ← ドイツでは小数点が "," に
// en-US: "1,234.50"

// ② FormattableString として受けると後でカルチャを指定できる
FormattableString raw = $"{price:N2}";
string invariant = raw.ToString(CultureInfo.InvariantCulture);
// どこで実行しても "1,234.50"

// ③ より簡潔に: FormattableString.Invariant()
string iv = FormattableString.Invariant($"price = {price}, time = {DateTime.UtcNow:o}");

// ④ 明示的にカルチャを指定
string de = string.Create(CultureInfo.GetCultureInfo("de-DE"), $"{price:N2}");
// → "1.234,50"

// 実務での使い分け:
// - ユーザー表示 → $"{value:N2}"(現在カルチャ)
// - JSON・API・ログ・DB → FormattableString.Invariant(...)
// - 特定カルチャ固定 → string.Create(culture, ...)
ロケール混在で本番障害が起きがち
ドイツのサーバー(de-DE)で $"Price: {1234.5:N2}" を書くと「Price: 1.234,50」となり、これを JSON で返すとdecimal.Parse("1.234,50") は米国ロケールのクライアントでは失敗します。永続化・API・ログの文字列は常に InvariantCulture、ユーザー表示だけが CurrentCulture というルールを徹底してください。サーバーのロケールを UTC + InvariantCulture に固定する設定も有効です。

内部実装 — C# 10 の DefaultInterpolatedStringHandler

C# 10 から文字列補間はコンパイラによってDefaultInterpolatedStringHandler を使った効率的な IL に変換されます。以前は string.Format(format, args) 相当のコードが生成されていましたが、現在は「StringBuilder 相当のバッファ + TryFormat」によるボクシングなし・中間文字列生成なしの実装になっています。

コンパイラが生成する IL(概念)
// 書いたコード
int x = 42;
string name = "Alice";
string msg = $"Hello {name}, value={x}";

// C# 10+ ではおおよそ以下のような IL が生成される
var handler = new DefaultInterpolatedStringHandler(
    literalLength: 14,       // "Hello " と ", value=" の合計長
    formattedCount: 2);      // name と x
handler.AppendLiteral("Hello ");
handler.AppendFormatted(name);
handler.AppendLiteral(", value=");
handler.AppendFormatted(x);
string msg2 = handler.ToStringAndClear();

// 効果:
// - string.Format の "object[] に args を詰め込む" 部分が不要
// - 値型のボクシングなし
// - 中間的な string が作られない
// - 多くのケースで string.Format より 2〜3倍高速
string.Create でバッファ上に直接生成(.NET 6+)
// 固定長バッファに対して補間を「その場で」書き込む API
Span<char> buffer = stackalloc char[256];
int x = 42;

bool success = buffer.TryWrite($"x = {x}", out int charsWritten);
// → スタック上のバッファに書き込み、ヒープ確保ゼロ

// string を最終的に取得する場合
string result = string.Create(CultureInfo.InvariantCulture,
    stackalloc char[256], $"x = {x:N0}");

// DefaultInterpolatedStringHandler を公開メソッドで直接受けることもできる
public void LogValue(
    [InterpolatedStringHandlerArgument("")]
    ref DefaultInterpolatedStringHandler handler)
{
    Console.WriteLine(handler.ToStringAndClear());
}

カスタム補間ハンドラー(C# 10+)

ログレベル無効時は文字列生成を完全スキップ
using System.Runtime.CompilerServices;

// ILogger の Information が無効なとき、
// 通常の $"..." は文字列を作ってから捨てる(コスト発生)
// カスタムハンドラーを使えば「必要なときだけ」構築できる

[InterpolatedStringHandler]
public ref struct LogInfoHandler
{
    private DefaultInterpolatedStringHandler _inner;
    private readonly bool _enabled;

    public LogInfoHandler(int literalLength, int formattedCount,
        ILogger logger, out bool isEnabled)
    {
        _enabled = logger.IsEnabled(LogLevel.Information);
        isEnabled = _enabled;                                // ★ ここがポイント
        _inner = _enabled
            ? new DefaultInterpolatedStringHandler(literalLength, formattedCount)
            : default;
    }

    public void AppendLiteral(string s)
    {
        if (_enabled) _inner.AppendLiteral(s);
    }
    public void AppendFormatted<T>(T value)
    {
        if (_enabled) _inner.AppendFormatted(value);
    }
    public string ToStringAndClear() => _enabled ? _inner.ToStringAndClear() : "";
}

public static class LoggerExt
{
    public static void LogInfo(this ILogger logger,
        [InterpolatedStringHandlerArgument(nameof(logger))]
        ref LogInfoHandler handler)
    {
        // isEnabled が false なら handler は default(何も処理されない)
        // true のときだけ文字列が構築される
        logger.LogInformation(handler.ToStringAndClear());
    }
}

// 使用側: 普通の $"..." と同じ見た目で書ける
logger.LogInfo($"Request {id} processed in {elapsed.TotalMs}ms");
// → Information が無効なら文字列生成コストが完全にスキップされる
.NET の高頻度ログでは標準で最適化済み
ILogger.LogInformation($"...") は C# 10+ で「ログレベルが無効なら文字列を構築しない」カスタムハンドラーが組み込まれています。とはいえ構造化ログの観点からは $”…” を直接 LogInformation に渡すべきではない(後述)点に注意してください。

ILogger との組み合わせ(重要)

文字列補間は構造化ログを壊す
int userId = 42;
string orderId = "ORD-001";

// NG: 文字列補間でログを書くと、プロパティが構造化されない
logger.LogInformation($"User {userId} placed order {orderId}");
// → Seq / Elasticsearch で見ると "User 42 placed order ORD-001" の1文字列
// → {UserId: 42} でフィルタできない

// OK: メッセージテンプレート(ただの文字列)+ 引数
logger.LogInformation("User {UserId} placed order {OrderId}", userId, orderId);
// → Properties: { "UserId": 42, "OrderId": "ORD-001" }
// → UserId = 42 で検索・集計できる

// コードアナライザー CA2254 で NG パターンを検出できる
// <WarningsAsErrors>CA2254</WarningsAsErrors> でビルド時エラー化推奨

// 補間を使ってよい場面:
// - UI 表示用の文字列
// - 例外メッセージ(throw new InvalidOperationException($"Value {x} invalid"))
// - デバッグ用の Console.WriteLine
// - SQL 以外の構造を持たないテキスト生成

SQL / HTML 安全性 — FormattableString の活用

SQL インジェクションを防ぐ FormattableString
using Microsoft.EntityFrameworkCore;

string userInput = "'; DROP TABLE Users; --";

// NG: 文字列補間で SQL を組み立てる → SQLインジェクション
string sqlBad = $"SELECT * FROM Users WHERE Name = '{userInput}'";
dbContext.Users.FromSqlRaw(sqlBad);  // 危険!

// OK: FromSqlInterpolated は FormattableString を受け取り、
//     パラメータ化クエリに変換する
var users = dbContext.Users
    .FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {userInput}")
    .ToList();
// 実行される SQL: SELECT * FROM Users WHERE Name = @p0
// → @p0 に userInput がバインドされるので SQL インジェクション耐性あり

// Dapper も同様に FormattableString を安全に扱うオーバーロードを提供する
HTML 安全性 — Razor でもエスケープが効く
// Razor では @() 内で補間文字列を書いても自動 HTML エスケープされる
@{
    string userName = "<script>alert(1)</script>";
}
<p>@($"ようこそ {userName} さん")</p>
// → <p>ようこそ &lt;script&gt;alert(1)&lt;/script&gt; さん</p>
// 安全にエスケープされる

// 手動でエスケープが必要な場面(HTML を直接組み立てるとき)
using System.Net;
string safe = WebUtility.HtmlEncode(userName);
string html = $"<p>Hello {safe}</p>";

// JavaScript への埋め込みは JS エスケープを使う
string jsString = JsonEncodedText.Encode(userName);
string jsCode = $"var user = \"{jsString}\";";

パフォーマンスと最適化

用途 書き方 速度 アロケーション
2〜3個の値 $"{a} {b}" 高速 最小
多数の値 + ループ StringBuilder 最速 最小
固定バッファ string.Create(ci, Span, $"...") 最速 ほぼゼロ
高頻度ログ [LoggerMessage] ソースジェネレーター 最速 ボクシングなし
string.Format string.Format("{0}", a) 遅め object[] ボクシング
ループ内での文字列連結比較
// NG: ループ内で補間(ヒープ確保が件数分発生)
string result = "";
foreach (var item in items)
    result += $"{item.Name}: {item.Price:C}
";

// OK: StringBuilder(.NET 6+ は AppendInterpolatedStringHandler 最適化あり)
var sb = new StringBuilder();
foreach (var item in items)
    sb.Append($"{item.Name}: {item.Price:C}
");
string result2 = sb.ToString();

// OK: string.Join + Select(簡潔)
string result3 = string.Join("
",
    items.Select(item => $"{item.Name}: {item.Price:C}"));

よくある落とし穴

落とし穴① — null 値の埋め込み
string? nullStr = null;
int? nullInt = null;

// null は空文字列として出力される(例外にはならない)
Console.WriteLine($"value = {nullStr}");    // "value = "
Console.WriteLine($"value = {nullInt}");    // "value = "

// 「null」と表示したい場合は明示
Console.WriteLine($"value = {nullStr ?? "(null)"}");
Console.WriteLine($"value = {nullInt?.ToString() ?? "(null)"}");

// null チェックなしのプロパティアクセスは NRE
User? user = null;
Console.WriteLine($"name = {user.Name}");  // NullReferenceException!
Console.WriteLine($"name = {user?.Name ?? "(unknown)"}");  // OK
落とし穴② — 複雑な式を書きすぎる
// NG: 補間の中に複雑な式を書くと可読性が崩壊
Console.WriteLine($"{(user.IsAdmin ? (user.SuperAdmin ? "SA" : "A") : (user.IsActive ? "U" : "G"))}");

// OK: 事前に変数化
string role = user switch
{
    { IsAdmin: true, SuperAdmin: true } => "SA",
    { IsAdmin: true }                   => "A",
    { IsActive: true }                  => "U",
    _                                   => "G",
};
Console.WriteLine($"{role}");
落とし穴③ — 書式指定子の誤用
int value = 255;

// NG: : の後は書式指定子として解釈される(時刻ではない)
Console.WriteLine($"{value:D2}");  // "255"(D2 は最小2桁 = 実際の桁優先)

// 期待: 16進表示 → X4 を使う
Console.WriteLine($"{value:X4}");  // "00FF"

// 時刻と混同しがち
DateTime time = DateTime.Now;
Console.WriteLine($"{time:HH:mm:ss}");    // OK: 18:30:45
Console.WriteLine($"{time:hh:mm:ss tt}"); // 12時間表記 + AM/PM
落とし穴④ — ログでの構造化ログ破壊
// 5922 の記事でも扱ったが、最重要なので再掲
var userId = 42;

// NG: 文字列補間をログ API に渡す
logger.LogInformation($"User {userId} logged in");

// OK: メッセージテンプレート
logger.LogInformation("User {UserId} logged in", userId);

// 例外メッセージなどの「構造化不要なテキスト」には $"..." OK
throw new ArgumentException($"Value {userId} is invalid");

よくある質問

Q$”…” と string.Format はどちらを使うべきですか?
AC# 6 以降のコードでは常に $"..."(文字列補間)を使ってください。可読性が高く、C# 10+ では DefaultInterpolatedStringHandler により string.Format より高速です。string.Format を使うのは「フォーマット文字列を変数で保持して後で適用する」場合(テンプレートの動的生成・リソースファイル)だけです。
QCultureInfo を毎回指定するのは面倒ですが、良い方法は?
Aプロジェクト全体のカルチャを固定する方法があります。アプリ起動時に Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; を設定すると全スレッドで Invariant が既定になります。ただしユーザー向け表示部分(Web UI)では CurrentCulture が必要なので、「API / バックエンドは Invariant、フロントエンドは CurrentCulture」という使い分けを意識してください。サーバーアプリでは UseInvariantCulture=true.csproj に設定することも選択肢です。
QC# 11 の raw string は従来の $@ を完全に置き換えますか?
A置き換え可能ですが、2〜3行の短い補間は従来の $"..." が簡潔です。raw string は主に「JSON・SQL・HTML など複雑な構造をエスケープなしで書きたい場面」で威力を発揮します。1行の補間でまで $"""...""" を使うと逆に冗長になります。「エスケープが多い長めのリテラル」→ raw string、「短い1行補間」→ 従来の $"..." という使い分けが現実的です。
Q文字列補間のパフォーマンスは本当に速いですか?
AC# 10+ ではほとんどの場面で string.Format より 2〜3倍高速です。DefaultInterpolatedStringHandler により object[] の確保とボクシングが排除されるためです。ただしループ内で数千回文字列連結するならば依然として StringBuilder が最速で、さらに高頻度の場合は string.Create + Span<char> で stackalloc バッファに直接書き込むパターンが採用されます。通常の業務コードは文字列補間で十分です。
Q日時のフォーマットはどのパターンを推奨しますか?
Aログ・API・DB には ISO 8601({date:o} または {date:yyyy-MM-ddTHH:mm:ssZ}ユーザー表示には {date:yyyy-MM-dd} など簡潔な形式を使い分けます。:o(ラウンドトリップ)は解析も容易で時刻情報を完全に保持するため永続化に最適です。DateTime.Now を使うとローカル時刻になるため、サーバーでは DateTime.UtcNow + :o の組み合わせが標準です。

まとめ

項目 ベストプラクティス
基本 $"{式:書式}"。中かっこ自体は {{ / }}
書式指定子 :C/:N2/:X4/:yyyy-MM-dd
幅指定 {式,幅:書式}(負値で左寄せ)
verbatim $@"..." または @$"..."
raw string $$"""..."""(C# 11+、JSON・SQL に最適)
CultureInfo 制御 永続化・API は FormattableString.Invariant()
構造化ログ $"..."LogInformation に渡さない
SQL FromSqlInterpolated($"...")(EF Core)で SQL インジェクション防止
HTML Razor @() は自動エスケープ。直書きは WebUtility.HtmlEncode
C# 10 最適化 DefaultInterpolatedStringHandlerstring.Format より高速
ループ内連結 StringBuilder or string.Join
null 値 {val ?? "(null)"} で明示表示

関連する文字列機能は以下を参照してください。文字列操作完全ガイドで Substring・Split・Trim 等、ログ出力完全ガイドで構造化ログと [LoggerMessage]DateTime 完全ガイドで日時書式の詳細、値型と参照型完全ガイドで string 不変性の仕組みを解説しています。