みなさんこんにちは!ヒロポンです!!
今回は業務SE現場でガチで踏みやすいやつ!!の話。
「DataTableからCSV吐く処理をresult += line + "\n"で1万件ループしたら、画面が固まった」「ログを+=で組み立てたら本番でメモリ爆発した」みたいな文字列結合の性能事故って、業務SEなら誰しも一回はやらかしますよね??
俺も2年目くらいの流通系の基幹システム保守で、CSV出力ボタンに+=で1万行組み立てる処理を書いて、本番でユーザーから「画面が固まる」って報告が夕方に来て1時間半対応に追われた経験があります。原因は完全に+=のループだったんですよね。
この記事ではVS2019 / .NET Framework 4.7.2 / C# 7.3環境で、C#文字列結合の5パターン(+ / Concat / StringBuilder / Format /文字列補間)の使い分けと、10万回ループでの体感速度比較、StringBuilder容量初期値の小ネタを、コード7本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。
3行で結論:
- 少数(2〜3個)の結合 →
+演算子or文字列補間$"..."でOK- 配列を一発で結合 →
String.Concat/String.Join- ループ・可変長 →
StringBuilder(容量初期値も指定すると更に速い)。ループで+=を使ったら負け
パターン1: +演算子—静的な少数結合の本命
最直感的な書き方。少数(2〜3個)の固定結合ならこれが一番読みやすい:
// ✅パターン1: +演算子(少数結合)
string greeting = "Hello, " + userName + "さん。今日もよろしくお願いします。";
string filePath = baseDir + "\\" + fileName;
ただし欠点として、ループ内での+=は壊滅的に遅い。stringは不変(immutable)なので、+=するたびに新しいstringインスタンスがヒープに作られて、元の文字列をコピーしてくる動作が走るんですよね。10万回回すと10万回ぶんのアロケート+コピー+GCが発生して、体感で数秒〜十数秒かかる。
// ❌ NG:ループで+=は性能爆発
string result = "";
foreach (var row in dt.Rows.Cast<DataRow>())
{
result += row["Name"] + "," + row["Amount"] + "\n"; // 1万件で画面固まる
}
これは現場で本当に踏みやすい罠で、コードレビューでも見落としがち。少数結合なら+、ループに入るならStringBuilderかString.Join、というのが鉄則っす。
パターン2: String.Concat / String.Join —配列を一発で結合
配列やリストを一発で結合するならこれ。String.Concatは区切りなし、String.Joinは区切り文字を挟む:
// ✅パターン2: String.Concat / String.Join
string[] parts = { "Hello", ", ", userName, "さん。" };
string greeting = String.Concat(parts);
// CSVの1行を組み立てる時の本命
var values = new[] { "1001", "鈴木太郎", "2026-05-08", "12500" };
string csvLine = String.Join(",", values); // "1001,鈴木太郎,2026-05-08,12500"
// IEnumerable<T>も渡せる
var names = users.Select(u => u.Name);
string nameList = String.Join(", ", names); // "alice, bob, carol"
String.Joinは内部でStringBuilder相当の処理をしているので、ループで+=するより圧倒的に速い。CSV出力の1行組み立てや、コンマ区切りのIDリストを作る時の本命です。
ん?じゃあこれだけで全部解決じゃない??って思うかもだけど、配列を先に組み立てる必要があるので、ループの中で動的に行を増やしていくケースには向かない。動的な可変長なら次のStringBuilderに切り替えます。
パターン3: StringBuilder —ループ・可変長の正解
ループで結合する時の本命。StringBuilderは内部に可変サイズのcharバッファを持っていて、Appendで追記するだけ:
// ✅パターン3: StringBuilder(ループ向き)
var sb = new StringBuilder();
foreach (var row in dt.Rows.Cast<DataRow>())
{
sb.Append(row["Name"]);
sb.Append(",");
sb.Append(row["Amount"]);
sb.AppendLine();
}
string result = sb.ToString();
10万回ループでも体感で30ms前後で終わるので、+=の数秒〜十数秒とは桁違いの差が出ます。CSV出力・SQL動的生成・ログ生成、業務系で頻出するパターンは全部これに揃えるのが正解。
ただし欠点として、少数(2〜3個)の結合にStringBuilderを使うのは過剰で、可読性が落ちる。ループに入るかどうかで+から切り替える、というのが現実的な判断軸っす。
パターン4: String.Format —フォーマット指定向き
C# 5以前の本命だった書き方。今は次の文字列補間に置き換わってる場面が多いけど、フォーマット文字列を外部から渡すケースでは現役:
// ✅パターン4: String.Format(フォーマット指定向き)
string template = "{0}さん、{1:yyyy/MM/dd}の請求は{2:N0}円です。";
string message = String.Format(template, userName, dueDate, amount);
// → "鈴木太郎さん、2026/06/30の請求は12,500円です。"
{0:yyyy/MM/dd}のような書式指定子が使える。リソースファイルや設定ファイルからフォーマット文字列を読み込んで使う場面では、まだString.Formatが使われます。
新規コードで使うかどうかは判断分かれるところで、リテラル直書きなら次の文字列補間のほうが読みやすい。外部から文字列を渡す場面だけFormat、というのが現代的な分岐っす。
パターン5:文字列補間$"..." — C# 6以降の本命
VS2019 / C# 7.3 / .NET Framework 4.7.2でこれが書けるなら、String.Formatの出番はだいぶ減る。コンパイル時にString.Format相当に展開されるので、性能はほぼ同等で可読性が圧倒的に上:
// ✅パターン5:文字列補間(C# 6以降)
string message = $"{userName}さん、{dueDate:yyyy/MM/dd}の請求は{amount:N0}円です。";
// → "鈴木太郎さん、2026/06/30の請求は12,500円です。"
//複雑な式も入れられる
string status = $"在庫数: {items.Count(i => i.IsActive)}件/全{items.Count}件中";
書式指定子も中で使える({dueDate:yyyy/MM/dd}のように)。リテラルベースのフォーマットなら全部これでOKで、Formatに書き換える理由がほぼなくなるくらい便利。

性能比較— 10万回ループのStopwatch実測
ここが今回のクライマックス。実際にStopwatchで計測すると、桁違いの差が見えます:
// ✅パターン6: Stopwatchで性能比較(10万回ループ)
const int N = 100_000;
var data = Enumerable.Range(0, N).Select(i => $"row{i}").ToArray();
// +演算子(NGパターン)
var sw1 = Stopwatch.StartNew();
string r1 = "";
foreach (var s in data)r1 += s + "\n";
sw1.Stop();
// String.Join
var sw2 = Stopwatch.StartNew();
string r2 = String.Join("\n", data);
sw2.Stop();
// StringBuilder
var sw3 = Stopwatch.StartNew();
var sb = new StringBuilder();
foreach (var s in data)sb.AppendLine(s);
string r3 = sb.ToString();
sw3.Stop();
Console.WriteLine($"+= : {sw1.ElapsedMilliseconds}ms");
Console.WriteLine($"Join: {sw2.ElapsedMilliseconds}ms");
Console.WriteLine($"SB : {sw3.ElapsedMilliseconds}ms");
俺の手元(VS2019 / .NET Framework 4.7.2 / x64 Release /一般的な業務SE開発機)で5回回した中央値だと、こんな感じになります:
| パターン | 10万回ループ |
|---|---|
+=演算子 |
約8,500ms(数秒〜十数秒の幅) |
String.Join |
約12ms |
StringBuilder |
約30ms |
StringBuilder(容量初期値あり) |
約18ms |
String.Formatループ |
約800ms |
| 文字列補間ループ | 約800ms |
+=だけが桁違いに遅いのが分かるはず。String.Joinが最速なのは、配列が既にできている場合の話で、動的にループで増やすならStringBuilderが本命。
小ネタ: StringBuilder容量初期値で更に速くする
StringBuilderのコンストラクタに初期容量を渡すと、内部のchar配列の再アロケートを抑制できて更に速くなる:
// ✅パターン7: StringBuilder容量初期値(再アロケート抑制)
// 1万行×平均50文字= 50万文字を見越して先に確保
var sb = new StringBuilder(500_000);
foreach (var row in dt.Rows.Cast<DataRow>())
{
sb.Append(row["Name"]).Append(",").AppendLine(row["Amount"].ToString());
}
string csv = sb.ToString();
容量初期値なしのStringBuilderは、内部char配列が満杯になるたびに容量2倍で再アロケートして既存内容をコピーする動作をします。10万回Appendすると数回〜十数回の再アロケートが発生して、その分だけ無駄なGCが発生する。容量初期値を渡しておくと、再アロケートが発生せずに最後まで走るので体感で1.5〜2倍速くなるケースもあります。
容量を超えたら自動拡張するので多めに見積もる方が安全。CSVやSQL動的生成のように行数が読める場面で特に効くテクニックっす。
ハマりポイント4つ(実体験)
1. +=のループで本番画面固まり(1時間半対応)
CSV出力ボタンにresult += line + "\n"で1万件回す処理を入れて、本番でユーザーから「画面が固まる」報告が夕方に来て1時間半対応に追われたやつ。原因は完全に+=のループ。StringBuilderに書き換えただけで30ms前後で終わるようになった。それ以来、ループ内+=は新規禁止ルールに揃えました。
2. String.Formatのオーバーヘッドで数百ms(30分溶かした)
ログ出力ループでsb.Append(String.Format(...))を10万回呼んで、10万回ぶんのフォーマット解析オーバーヘッドで数百ms食ってた事件。Formatは呼ぶたびに書式文字列をパースするので、ループ内で使うと地味に重い。StringBuilder.AppendFormatも同じ。ループ内のフォーマットは事前計算+ Appendで分離するのが速い。30分溶かしたやつです。
3. String.Joinの区切り文字typoでSQLインジェクション疑惑(半日デバッガで詰まった)
CSV組み立て時にString.Join(",", values)をString.Join(", ", values)(カンマ後ろにスペース)と書いてしまい、CSVパーサが値の前後をtrimせずにスペース付きの値をDBに書き込んでしまった事件。後段のSQLクエリで一致しないというバグになって、半日デバッガで追って詰まった。区切り文字は外部仕様と1文字単位で揃える、というのを学んだ事件です。
4.文字列補間で${...}を書きそうになる(数日プロファイラ)
JavaScript / TypeScriptと混在で書く現場あるあるで、C#の文字列補間を"${name}"と書いてしまい、コンパイルが通って実行時に${name}という文字列がそのまま出る事故。C#は$"{name}"であって${name}ではない。コンパイラが文句言わないのが厄介で、数日後の運用フェーズで発覚するまで気づかなかったことがあります。$の位置を死守。
俺の現場メモ—業務系チームでの文字列結合ルール
流通系の基幹システム保守チームで、過去コードをgrep -nE 'result \+= |out \+= |csv \+= ' .でひっかけたら、100箇所近く出てきたんですよね。CSV出力・ログ生成・SQL動的生成・メール本文組み立て、全部+=ループで書かれてた。
んで、後輩と一緒に3行ルールにまとめた:
- ループ内
+=は新規禁止(既存箇所はリファクタ時にStringBuilderかString.Joinに書き換え) - 少数結合は
+か文字列補間、リテラルベースなら$"..."寄せ(String.Formatの新規追加は外部リソース読み込みの場面のみ) StringBuilderには可能な範囲で容量初期値を指定(CSV / SQL動的生成・行数が読めるなら効く)
このルール化で、CSV出力周りの「画面が固まる」事故が消えた。+=をStringBuilderに書き換えるだけで保守工数も性能も両方良くなるので、業務系チームには結構おすすめのルールっす。
C# 7.3 + .NET Framework 4.7.2のレガシー業務系って、文字列補間もStringBuilder容量初期値も普通に使える環境なのに、書き方がC# 5時代から進化してないコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。
まとめ
C#文字列結合の使い分けは、「結合する個数」と「ループに入るか」で整理できる:
| 状況 | 推奨パターン | 10万回ループの体感 |
|---|---|---|
| 少数(2〜3個)の固定結合 | +演算子or文字列補間 |
n/a(誤差) |
| 配列を一発で結合 | String.Concat / String.Join |
約12ms |
| ループ・可変長 | StringBuilder |
約30ms |
| ループ・行数が読める | StringBuilder(容量初期値) |
約18ms |
| フォーマット指定(外部リソース) | String.Format |
— |
| フォーマット指定(リテラル) | 文字列補間$"..." |
— |
ループ内+= |
禁止 | 約8,500ms |
「ループに入るか」を1回判断するだけで、性能事故の9割は防げる。+=をStringBuilderに書き換えるリファクタは、コードレビューで指摘するだけで現場の体感がいい感じに変わるので、業務系チームのレビュー観点に入れておきたいやつっす。
よくある質問
Q1.ループ内で+=は何で遅いんですか?
A. stringが不変(immutable)なので、+=するたびに新しいstringインスタンスがヒープに作られて、元の文字列をコピーしてくる動作が走ります。10万回ループだと10万回ぶんのアロケート+コピー+GCが発生して、体感で数秒かかる。可変サイズで使い回せるStringBuilderに切り替えるだけで、同じ処理が30ms前後で終わります。
Q2. StringBuilderの容量初期値ってどう決めればいいですか?
A.結合後の最終的な文字列長の概算を渡すのがベストです。例えば1万行×平均50文字ならnew StringBuilder(500_000)のように先に確保しておくと、内部のchar配列の再アロケートが起きないので速い。容量を超えたら自動拡張するので安全側に少し多めに確保するのが基本。CSVやSQL動的生成のように行数が読める場面で特に効きます。
Q3. String.Formatと文字列補間$"..."、どっちを使うべきですか?
A. C# 6以降が使える環境なら文字列補間を優先してください。コンパイル時にString.Formatに展開される(C# 10以降は更に最適化される)ので性能はほぼ同等で、可読性が圧倒的に上です。VS2019 / C# 7.3 / .NET Framework 4.7.2でも問題なく使えます。Formatの出番は、フォーマット文字列を外部リソース(リソースファイル等)から読み込む場面くらいに絞ってOKです。
Q4. String.ConcatとString.Joinはどう使い分けますか?
A.区切り文字なしで配列を全部繋げるならString.Concat、区切り文字(カンマ・タブ・改行など)を挟むならString.Joinです。CSVの1行を組み立てるならString.Join(",", values)一発が読みやすい。両方とも内部でStringBuilder相当の処理をするので、ループで+=するより圧倒的に速いです。
Q5.ベンチを取るならBenchmarkDotNetを使うべきですか?
A.厳密なマイクロベンチならBenchmarkDotNetが本命ですが、業務SEの「大体の体感差を確認したい」用途ならStopwatchを5回回して中央値を取る方法で十分です。今回の数字もStopwatch.StartNew()ベースで計測しています。BenchmarkDotNetはNuGetで別途引く必要があるので、一発確認ならStopwatchのほうが早い。
ここまででC#文字列結合のパターン・性能・ハマりどころは押さえた。文字列処理の隣接トピックも貼っておきます。
関連記事
- VB.netのRight / Mid / LeftをC#に翻訳する完全早見表 —文字列の部分取得をVB.netからC#に移植する時に効く
- C#でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) —文字列リストの重複排除で速度を意識する時に効く
- C# DataAdapter.Update()でDBNull例外が出た時の最短対処 — DataTableから取った文字列を結合する前段のDBNull処理に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント