C#のファイル読み書きは、「小さなファイルは File.ReadAllText/File.WriteAllText で一括」「大きなファイルは File.ReadLines か StreamReader で1行ずつ」「CSVは Split ではなく専用の読み取り処理」と使い分けるのが基本です。 一括で読むメソッドは1行で書けるかわりにファイル全体をメモリに持ち、1行ずつ読む方法はファイルの大きさに関係なく少ないメモリで動きます。

この記事では、読み書きのメソッドを早見表にまとめたうえで、100万行(約66MB)のファイルで測った時間とメモリ、.NET(Core以降)でShift_JISを指定すると例外になる現象の再現と Encoding.RegisterProvider での解決、BOMあり・なしの書き分け、Splitで崩れるCSVと正しい読み方、使用中のファイルで出る IOException や FileNotFoundException の対処までを扱います。掲載したコードと出力は、.NET SDK 8.0.131(ランタイム 8.0.31、Linux)で実行して確かめたものです。

C#ファイル読み書きの早見表

やりたいこと 書き方 ポイント
全体を文字列で読む File.ReadAllText(path) 既定はUTF-8。BOMは自動で外れる
全行を配列で読む File.ReadAllLines(path) string[]。行数が少ないとき
1行ずつ読む(手軽) foreach (var line in File.ReadLines(path)) 必要な行だけメモリに載る
1行ずつ読む(細かく制御) using var r = new StreamReader(path); → r.ReadLine() 文字コードや共有モードを指定できる
上書きで書く File.WriteAllText(path, text) 無ければ作る。BOMなしUTF-8
行の配列を書く File.WriteAllLines(path, lines) 各行に改行が付く
追記する File.AppendAllText(path, text) 末尾に足す
少しずつ書く using var w = new StreamWriter(path); → w.WriteLine() 大量の行を順に出すとき
非同期で読む・書く await File.ReadAllTextAsync(path) / await File.WriteAllTextAsync(path, text) 画面やWebの処理を止めない
Shift_JISで読む Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); → Encoding.GetEncoding("shift_jis") 登録は起動時に1回
BOM付きUTF-8で書く new UTF8Encoding(true) を渡す Excelで開くCSV向け
BOMなしUTF-8で書く new UTF8Encoding(false) を渡す(または省略) プログラム同士の受け渡し
CSVを読む TextFieldParser(標準)/CsvHelper(NuGet) 引用符・カンマ・改行を正しく扱う
あるか調べる File.Exists(path) / Directory.Exists(dir) 例外処理と合わせて使う
パスを組み立てる Path.Combine("data", "a.txt") 区切り文字の違いを吸収

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#ファイル読み書き|ReadAllText・StreamReader・CSV」

読み込み方法の比較と実測値

5つの方法の違い

方法 読み方 結果の形 メモリ 向く場面
File.ReadAllText 一括 1つの string ファイル全体(UTF-16で持つので、英数字中心ならファイルサイズの約2倍) 設定、JSON、テンプレート、数MBまで
File.ReadAllLines 一括 string[] 全行ぶん(行ごとの管理領域も加わる) 行数が少なく、添字で扱いたいとき
File.ReadLines 1行ずつ(遅延) IEnumerable<string> 1行ぶん 大きなファイル、LINQで絞り込み、途中で打ち切る
StreamReader.ReadLine 1行ずつ string? 1行ぶん 文字コード・共有モード・バッファを細かく指定したいとき
非同期版(ReadAllTextAsync、ReadLineAsync など) 一括または1行ずつ Task<...> 同期版と同じ考え方 画面(UI)やWebアプリで、待ち時間に他の処理を進めたいとき

ReadLines と ReadAllLines は名前が似ていますが、ReadLinesは foreach で回したぶんだけ読み進めるのに対し、ReadAllLinesは呼んだ時点で全行を配列にします。

100万行・約66MBのファイルで測った結果

1,000,000行(65,778,896バイト、UTF-8)のログ風ファイルを作り、各方法で全行を読んで行数を数えました。方法ごとに別のプロセスで実行しています。

// 計測用ファイルを作る
using (var w = new StreamWriter("big.log", false, new UTF8Encoding(false)))
{
    for (int i = 1; i <= 1_000_000; i++)
        w.Write($"{i:D7},2026-10-03 12:00:00,INFO,order created id={i} user={i % 1000}\n");
}

// 計測の例(ReadLines の場合)
long before = GC.GetTotalMemory(true);
var sw = Stopwatch.StartNew();
long n = 0, held = 0;
foreach (var l in File.ReadLines("big.log"))
{
    n++;
    if (n == 500_000) held = GC.GetTotalMemory(true) - before;   // 読んでいる途中で保持している量
}
sw.Stop();
Console.WriteLine($"行数={n} 時間={sw.ElapsedMilliseconds}ms 保持メモリ={held / 1048576.0:F1}MB 割り当て合計={GC.GetTotalAllocatedBytes() / 1048576.0:F1}MB");

「保持メモリ」は、一括で読む方法では読み終えた結果を持っている時点、1行ずつ読む方法では50万行目の時点で、ガベージコレクション後に残っていた量です。「割り当て合計」は処理全体で確保した量の合計です。

実行結果(.NET 8.0.31、Releaseビルド):

ReadAllText        行数=1000000 時間=  237ms 保持メモリ= 125.5MB 割り当て合計= 251.6MB
ReadAllLines       行数=1000000 時間=  340ms 保持メモリ= 152.6MB 割り当て合計= 168.7MB
ReadLines          行数=1000000 時間=  146ms 保持メモリ=   0.0MB 割り当て合計= 145.0MB
StreamReader       行数=1000000 時間=  128ms 保持メモリ=   0.0MB 割り当て合計= 145.0MB
ReadAllTextAsync   行数=1000000 時間=  359ms 保持メモリ= 251.5MB 割り当て合計= 254.0MB
ReadAllLinesAsync  行数=1000000 時間=  871ms 保持メモリ= 160.6MB 割り当て合計= 240.6MB
ReadLineAsync      行数=1000000 時間=  343ms 保持メモリ=   0.0MB 割り当て合計= 216.9MB
ReadLinesAsync     行数=1000000 時間=  496ms 保持メモリ=   0.0MB 割り当て合計= 216.9MB

読み取れるポイントは3つです。

  • 1行ずつ読む方法は、保持しているメモリがほぼゼロでした。割り当て合計が145MBあるのは、1行ごとに作られた文字列の合計で、読み終えた行は順に回収されています。
  • ReadAllTextは、ファイルサイズの約2倍を保持しました。.NETの文字列は内部でUTF-16(1文字2バイト)なので、英数字中心のUTF-8ファイルは読み込むと約2倍になります。
  • 非同期版は、同じ処理でも時間が長くなりました。 非同期の目的は「速くすること」ではなく、待っている間にスレッドを空けて、画面の操作や他のリクエストの処理を進められることです。コンソールで順に処理するだけなら同期版で十分です。

時間は1回の計測値で、ディスクキャッシュの状態やマシンによって前後します。なお、ReadAllTextAsync の保持メモリが同期版より大きく出ている理由は、今回は掘り下げて確認していません。計測値としてそのまま載せています。

メモリの上限を決めて動かすとどうなるか

ヒープの上限を100MBにして(環境変数 DOTNET_GCHeapHardLimit=0x6400000)、同じ処理を実行しました。

ReadAllText   → Out of memory.
ReadAllLines  → Out of memory.
ReadLines          行数=1000000 時間=   92ms 保持メモリ=   0.0MB 割り当て合計= 145.0MB
StreamReader       行数=1000000 時間=   81ms 保持メモリ=   0.0MB 割り当て合計= 145.0MB

一括で読む2つは「Out of memory.」と表示されて止まり、1行ずつ読む2つは最後まで動きました。コンテナのようにメモリの上限が決まっている環境では、大きさの読めないファイルは1行ずつ読むと安定します。

どれを選ぶかの判断表

状況 選ぶもの
数MBまでで、中身を丸ごと使う(JSON、設定) File.ReadAllText
行数が少なく、lines[3] のように添字で使いたい File.ReadAllLines
大きい、または大きさが読めない File.ReadLines
文字コードの自動判別や、他のプロセスが書いているファイルを読む StreamReader(FileStream と組み合わせる)
画面を止めたくない(WPF・WinForms・MAUI)、ASP.NET Coreのリクエスト処理 非同期版
先頭の数行だけ見たい File.ReadLines(path).Take(10)

基本の読み書き(コピペ用)

次の using を前提にします(dotnet new console で作ったプロジェクトの Program.cs に貼れば動きます)。

using System.Text;

一括で読む・書く・追記する

File.WriteAllText("memo.txt", "1行目\n2行目\n");      // 上書き(無ければ作る)
File.AppendAllText("memo.txt", "3行目\n");            // 追記

Console.Write(File.ReadAllText("memo.txt"));          // 全体を文字列で

string[] lines = File.ReadAllLines("memo.txt");       // 行の配列で
Console.WriteLine($"{lines.Length}行 / 先頭={lines[0]}");

File.WriteAllLines("list.txt", new[] { "りんご", "みかん" });   // 配列を1行ずつ書く
File.AppendAllLines("list.txt", new[] { "ぶどう" });
Console.WriteLine(string.Join("/", File.ReadAllLines("list.txt")));
1行目
2行目
3行目
3行 / 先頭=1行目
りんご/みかん/ぶどう

ReadAllLines と ReadLines が返す各行には、改行文字は含まれません。

1行ずつ読む(ReadLines・StreamReader)

// File.ReadLines:foreach で回すだけ
foreach (string line in File.ReadLines("memo.txt"))
{
    Console.WriteLine($"[{line}]");
}

// StreamReader:ReadLine が null を返したら終わり
using (var reader = new StreamReader("memo.txt"))
{
    string? line;
    int no = 0;
    while ((line = reader.ReadLine()) != null)
    {
        Console.WriteLine($"{++no}: {line}");
    }
}
[1行目]
[2行目]
[3行目]
1: 1行目
2: 2行目
3: 3行目

StreamReaderやStreamWriterは using を付けて開きます。 using のブロックを抜けるときに自動で閉じられ、書き込み側はバッファに残った内容もこのときにファイルへ出ます。using を付け忘れると、ファイルが開いたままになり、次に開くときに「使用中」の例外が出る原因になります。

少しずつ書く(StreamWriter)

using (var writer = new StreamWriter("out.txt", append: false))   // append: true で追記
{
    writer.WriteLine("商品ID,商品名");
    writer.WriteLine("1,ノート");
}

件数の多いデータを順に書き出すときは、全体を1つの文字列にまとめずに、StreamWriterで1行ずつ出すとメモリを使いません。

非同期で読む・書く

string text = await File.ReadAllTextAsync("memo.txt");
await File.WriteAllTextAsync("copy.txt", text);
await File.AppendAllTextAsync("copy.txt", "4行目\n");
Console.WriteLine((await File.ReadAllLinesAsync("copy.txt")).Length + "行");

// 1行ずつ(StreamReader)
using (var r = new StreamReader("copy.txt"))
{
    string? l;
    int c = 0;
    while ((l = await r.ReadLineAsync()) != null) c++;
    Console.WriteLine("ReadLineAsync " + c + "行");
}

// 1行ずつ(.NET 7以降は await foreach で書ける)
int cnt = 0;
await foreach (var l in File.ReadLinesAsync("copy.txt")) cnt++;
Console.WriteLine("ReadLinesAsync " + cnt + "行");

// 書き込み側は await using
await using (var w = new StreamWriter("async.txt"))
{
    await w.WriteLineAsync("非同期で書く");
}
4行
ReadLineAsync 4行
ReadLinesAsync 4行

存在確認とパスの組み立て

Console.WriteLine($"Exists={File.Exists("memo.txt")} Dir={Directory.Exists("nodir")} Combine={Path.Combine("data", "a.txt")}");
Exists=True Dir=False Combine=data/a.txt

Linuxで実行したので区切りは / ですが、Windowsでは data\a.txt になります。パスは文字列を + でつながず Path.Combine で組み立てると、OSの違いを意識せずに済みます。保存先のフォルダは Directory.CreateDirectory(dir) で作れます(既にあっても例外にはなりません)。

Shift_JISで読み書きする(RegisterProvider)

.NETでShift_JISを指定すると例外になる

.NET Frameworkでは Encoding.GetEncoding("shift_jis") がそのまま使えましたが、.NET Core以降(.NET 5/6/8など)では、何も準備せずに指定すると例外になります。

void Try(string label, Action f)
{
    try { f(); }
    catch (Exception e) { Console.WriteLine($"{label} => {e.GetType().FullName}: {e.Message}"); }
}

Try("GetEncoding(\"shift_jis\")", () => Encoding.GetEncoding("shift_jis"));
Try("GetEncoding(932)", () => Encoding.GetEncoding(932));
Try("ReadAllText(sjis)", () => File.ReadAllText("memo.txt", Encoding.GetEncoding("Shift_JIS")));

実行結果(.NET 8.0.31):

GetEncoding("shift_jis") => System.ArgumentException: 'shift_jis' is not a supported encoding name. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method. (Parameter 'name')
GetEncoding(932) => System.NotSupportedException: No data is available for encoding 932. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method.
ReadAllText(sjis) => System.ArgumentException: 'Shift_JIS' is not a supported encoding name. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method. (Parameter 'name')

名前で指定すると ArgumentException、コードページ番号(932)で指定すると NotSupportedException になりました。どちらのメッセージにも、解決の手がかりである Encoding.RegisterProvider が書かれています。

Microsoft Learnの CodePagesEncodingProvider のページ(今回開いて確認)によると、.NET Core/.NET 5以降が既定でサポートするのは、ASCII、ISO-8859-1、UTF-7、UTF-8、UTF-16(LE/BE)、UTF-32(LE/BE)です。Shift_JISのような各地域のコードページは、プロバイダーを登録して使えるようにします。

Encoding.RegisterProviderを1回呼ぶ

// アプリの起動時(Program.cs の最初など)に1回だけ呼ぶ
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

Encoding sjis = Encoding.GetEncoding("shift_jis");
Console.WriteLine($"登録後: {sjis.EncodingName} / CodePage={sjis.CodePage} / WebName={sjis.WebName}");

// Shift_JISで書く
File.WriteAllText("sjis.csv", "商品ID,商品名\r\n1,髙橋商店①\r\n2,表計算ソフト\r\n", sjis);

// 文字コードを指定せずに読む(UTF-8として読まれる)
Console.WriteLine("UTF-8として読む: " + File.ReadAllLines("sjis.csv")[1]);
// Shift_JISを指定して読む
Console.WriteLine("Shift_JISで読む: " + File.ReadAllLines("sjis.csv", sjis)[1]);
// StreamReader でも同じように指定できる
using (var sr = new StreamReader("sjis.csv", sjis))
    Console.WriteLine("StreamReader: " + sr.ReadLine());
登録後: Japanese (Shift-JIS) / CodePage=932 / WebName=shift_jis
UTF-8として読む: 1,�������X�@
Shift_JISで読む: 1,髙橋商店①
StreamReader: 商品ID,商品名

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); を1行足すだけで、Shift_JISが使えるようになりました。 「髙」「①」のようなWindows拡張の文字も読み書きできています(.NETの shift_jis はコードページ932に対応します)。

項目 内容
呼ぶ場所 アプリの起動時に1回(Program.cs の先頭など)。何度呼んでも動作は変わらない
NuGetパッケージ .NET 8のコンソールアプリでは、パッケージを追加せずに動いた。.NET Frameworkや.NET Standard向けのライブラリでは System.Text.Encoding.CodePages パッケージを追加する
指定のしかた Encoding.GetEncoding("shift_jis") または Encoding.GetEncoding(932)
文字コードを指定しないとき UTF-8として読まれ、Shift_JISのファイルは � の並びになる

テストプロジェクトや別の実行ファイルは、それぞれ別のプロセスなので、起動するプログラムごとに登録が必要です。単体テストでだけ例外が出る場合は、テスト側の初期化処理にも同じ1行を足します。

BOMあり・なしを書き分ける

BOM(バイトオーダーマーク)は、ファイルの先頭に付く目印のバイト列で、UTF-8では EF BB BF の3バイトです。.NETでは、文字コードの渡し方によってBOMが付くかどうかが変わります。 同じ「あ」1文字を7通りで書き、先頭のバイトを表示しました。

string Hex(string path, int n = 8) => BitConverter.ToString(File.ReadAllBytes(path).Take(n).ToArray());

File.WriteAllText("a_default.txt", "あ");                           // 文字コード省略
File.WriteAllText("b_utf8.txt", "あ", Encoding.UTF8);               // Encoding.UTF8
File.WriteAllText("c_nobom.txt", "あ", new UTF8Encoding(false));    // BOMなしを明示
File.WriteAllText("d_bom.txt", "あ", new UTF8Encoding(true));       // BOMありを明示
using (var w = new StreamWriter("e_sw.txt")) w.Write("あ");                          // StreamWriter 省略
using (var w = new StreamWriter("f_sw_utf8.txt", false, Encoding.UTF8)) w.Write("あ"); // StreamWriter + Encoding.UTF8
File.WriteAllText("g_utf16.txt", "あ", Encoding.Unicode);           // UTF-16

foreach (var f in new[] { "a_default.txt", "b_utf8.txt", "c_nobom.txt", "d_bom.txt", "e_sw.txt", "f_sw_utf8.txt", "g_utf16.txt" })
    Console.WriteLine($"{f,-14} {new FileInfo(f).Length}bytes {Hex(f)}");
a_default.txt  3bytes E3-81-82
b_utf8.txt     6bytes EF-BB-BF-E3-81-82
c_nobom.txt    3bytes E3-81-82
d_bom.txt      6bytes EF-BB-BF-E3-81-82
e_sw.txt       3bytes E3-81-82
f_sw_utf8.txt  6bytes EF-BB-BF-E3-81-82
g_utf16.txt    4bytes FF-FE-42-30
書き方 BOM 使いどころ
文字コードを省略 なし 設定ファイル、JSON、プログラム同士の受け渡し
Encoding.UTF8 を渡す あり 省略時と結果が違う点に注意
new UTF8Encoding(false) なし BOMなしを明示したいとき
new UTF8Encoding(true) あり Excelで開くCSV
Encoding.Unicode(UTF-16) あり(FF FE) UTF-16を指定された連携

「省略するとBOMなし、Encoding.UTF8 を渡すとBOMあり」という違いが、つまずきやすい点です。意図をはっきりさせたいときは、new UTF8Encoding(false) か new UTF8Encoding(true) と書くと、読む人にも伝わります。

読むときのBOMの扱い

string viaText = File.ReadAllText("d_bom.txt");
Console.WriteLine($"ReadAllText: Length={viaText.Length} 先頭=U+{(int)viaText[0]:X4}");

string viaBytes = Encoding.UTF8.GetString(File.ReadAllBytes("d_bom.txt"));
Console.WriteLine($"GetString(ReadAllBytes): Length={viaBytes.Length} 先頭=U+{(int)viaBytes[0]:X4} == \"あ\": {viaBytes == "あ"}");
Console.WriteLine($"TrimStart: {viaBytes.TrimStart((char)0xFEFF) == "あ"}");

Console.WriteLine("UTF-16をReadAllText: " + File.ReadAllText("g_utf16.txt"));
ReadAllText: Length=1 先頭=U+3042
GetString(ReadAllBytes): Length=2 先頭=U+FEFF == "あ": False
TrimStart: True
UTF-16をReadAllText: あ
  • File.ReadAllText や StreamReader は、BOMを自動で外して読みます。 BOMからUTF-16なども判別するので、UTF-16のファイルも文字コードを指定せずに読めました。
  • バイト列を Encoding.UTF8.GetString で文字列にすると、BOMが先頭の1文字(U+FEFF)として残ります。 見た目は同じでも == が False になるので、この経路で読むときは先頭のU+FEFFを取り除きます。
  • BOMから判別できるのはUTF-8/UTF-16/UTF-32です。Shift_JISにはBOMが無いので、読む側で文字コードを指定します。

Excelで開くCSVはBOM付きUTF-8で書く

using (var w = new StreamWriter("excel_utf8.csv", false, new UTF8Encoding(true)))
{
    w.NewLine = "\r\n";            // 改行をCRLFにそろえる
    w.WriteLine("商品ID,商品名");
    w.WriteLine("1,髙橋商店①");
}
Console.WriteLine("excel_utf8.csv " + Hex("excel_utf8.csv", 6));
excel_utf8.csv EF-BB-BF-E5-95-86

先頭にBOM(EF-BB-BF)が付いていることを確認しました。Excelで開いての表示確認は、この検証環境にExcelが無いため未実行です。

追記のときは、既にあるファイルにBOMが重ねて書かれることはありませんでした。

File.AppendAllText("d_bom.txt", "い", new UTF8Encoding(true));
Console.WriteLine("追記後 " + BitConverter.ToString(File.ReadAllBytes("d_bom.txt")));
追記後 EF-BB-BF-E3-81-82-E3-81-84

CSVを正しく読む・書く

Splitで分けると崩れる例

CSVは、値の中にカンマ・改行・引用符を含められる形式です。次の items.csv は見出しを含めて3件のデータですが、2件目の値の中にカンマと改行、3件目に引用符が入っています。

商品ID,商品名,備考
1,"ノート,A4","1行目
2行目"
2,髙橋商店①,"彼は""OK""と言った"

これを1行ずつ読んで Split(',') で分けます。

foreach (string line in File.ReadLines("items.csv"))
{
    string[] cols = line.Split(',');
    Console.WriteLine($"列数={cols.Length}: {string.Join(" | ", cols)}");
}
列数=3: 商品ID | 商品名 | 備考
列数=4: 1 | "ノート | A4" | "1行目
列数=1: 2行目"
列数=3: 2 | 髙橋商店① | "彼は""OK""と言った"

3列のはずが4列と1列に分かれ、引用符も残ったままになりました。原因は3つあります。

起きたこと 原因
ノート,A4 が2つに分かれた 引用符の中のカンマも区切りとして扱われた
1件が2行に分かれた 値の中の改行で、行として切られた
" や "" が残った 引用符を外す処理、"" を " に戻す処理が無い

カンマも改行も引用符も入らないと分かっているデータならSplitで足りますが、人が入力した文字列を含むCSVには、次の方法を使います。

標準ライブラリのTextFieldParserで読む

Microsoft.VisualBasic.FileIO.TextFieldParser は、名前に VisualBasic と付きますが、C#からそのまま使えます。.NET 8のコンソールアプリでは、パッケージを追加せずに使えました。

using System.Text;
using Microsoft.VisualBasic.FileIO;

using (var parser = new TextFieldParser("items.csv", Encoding.UTF8))
{
    parser.TextFieldType = FieldType.Delimited;
    parser.SetDelimiters(",");
    parser.HasFieldsEnclosedInQuotes = true;   // 引用符で囲まれた値を扱う
    parser.TrimWhiteSpace = false;             // 前後の空白をそのまま残す

    while (!parser.EndOfData)
    {
        string[] cols = parser.ReadFields()!;
        Console.WriteLine($"列数={cols.Length}: {string.Join(" | ", cols.Select(c => c.Replace("\r\n", "\\r\\n")))}");
    }
}
列数=3: 商品ID | 商品名 | 備考
列数=3: 1 | ノート,A4 | 1行目\r\n2行目
列数=3: 2 | 髙橋商店① | 彼は"OK"と言った

(値の中の改行は、表示のために \r\n という文字に置き換えています。)3件とも3列で読め、引用符も外れています。Shift_JISのCSVは、第2引数に登録済みのエンコーディングを渡します。

using (var parser = new TextFieldParser("sjis.csv", sjis))
{
    parser.SetDelimiters(",");
    while (!parser.EndOfData)
        Console.WriteLine(string.Join(" | ", parser.ReadFields()!));
}
商品ID | 商品名
1 | 髙橋商店①
2 | 表計算ソフト

引用符が閉じていないなど、形式が崩れた行があると MalformedLineException が出ます。

Microsoft.VisualBasic.FileIO.MalformedLineException: Line 1 cannot be parsed using the current Delimiters.

取り込み処理では、この例外を捕まえて「何行目が読めなかったか」を利用者に伝える形にすると、原因を探しやすくなります。

外部ライブラリなしで読む・書くクラス

仕組みを理解したい場合や、依存を増やしたくない場合に使える、最小限の読み書きクラスです。引用符の中のカンマ・改行と、"" の扱いに対応しています。

using System.Text;

static class Csv
{
    // 1件ずつ返す(大きなファイルでも全体をメモリに載せない)
    public static IEnumerable<List<string>> Read(string path, Encoding enc)
    {
        using var reader = new StreamReader(path, enc);
        var row = new List<string>();
        var field = new StringBuilder();
        bool inQuotes = false, any = false;
        int ch;
        while ((ch = reader.Read()) != -1)
        {
            char c = (char)ch;
            any = true;
            if (inQuotes)
            {
                if (c == '"')
                {
                    if (reader.Peek() == '"') { field.Append('"'); reader.Read(); }   // "" は " 1文字
                    else inQuotes = false;
                }
                else field.Append(c);
            }
            else if (c == '"') inQuotes = true;
            else if (c == ',') { row.Add(field.ToString()); field.Clear(); }
            else if (c == '\r' || c == '\n')
            {
                if (c == '\r' && reader.Peek() == '\n') reader.Read();
                row.Add(field.ToString()); field.Clear();
                yield return row;
                row = new List<string>(); any = false;
            }
            else field.Append(c);
        }
        if (any) { row.Add(field.ToString()); yield return row; }
    }

    // カンマ・引用符・改行を含む値だけ引用符で囲み、" は "" にする
    public static string Escape(string v) =>
        v.IndexOfAny(new[] { ',', '"', '\r', '\n' }) >= 0 ? "\"" + v.Replace("\"", "\"\"") + "\"" : v;

    public static void Write(string path, IEnumerable<string[]> rows, Encoding enc)
    {
        using var w = new StreamWriter(path, false, enc);
        foreach (var r in rows)
        {
            w.Write(string.Join(",", r.Select(Escape)));
            w.Write("\r\n");
        }
    }
}

使う側(トップレベルステートメントの場合、クラス定義より上に書きます):

foreach (var row in Csv.Read("items.csv", Encoding.UTF8))
    Console.WriteLine($"列数={row.Count}: {string.Join(" | ", row.Select(c => c.Replace("\r\n", "\\r\\n")))}");

var rows = new List<string[]>
{
    new[] { "商品ID", "商品名", "備考" },
    new[] { "1", "ノート,A4", "1行目\r\n2行目" },
    new[] { "2", "髙橋商店①", "彼は\"OK\"と言った" },
};
Csv.Write("out.csv", rows, new UTF8Encoding(true));    // BOM付きUTF-8で書く
Console.Write(File.ReadAllText("out.csv"));
列数=3: 商品ID | 商品名 | 備考
列数=3: 1 | ノート,A4 | 1行目\r\n2行目
列数=3: 2 | 髙橋商店① | 彼は"OK"と言った
商品ID,商品名,備考
1,"ノート,A4","1行目
2行目"
2,髙橋商店①,"彼は""OK""と言った"

書き出したファイルを同じクラスで読み戻し、元のデータと一致することも確かめました(往復一致=True)。

CsvHelperを使う場合(未実行)

列をクラスのプロパティに対応づけたい、型変換もまとめて任せたい、というときは、NuGetパッケージの CsvHelper がよく使われます。今回の検証環境はNuGetに接続できないため、次のコードは未実行です。 CsvHelperの一般的な使い方として載せています。導入時は、利用する版のドキュメントで確認してください。

// dotnet add package CsvHelper
using System.Globalization;
using CsvHelper;

using var reader = new StreamReader("items.csv", Encoding.UTF8);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
List<Item> items = csv.GetRecords<Item>().ToList();   // 見出し行の名前とプロパティ名を対応づける

public class Item
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Note { get; set; } = "";
}
方法 追加のパッケージ 向く場面
Split(',') 不要 カンマ・改行・引用符が入らないと決まっているデータ
TextFieldParser 不要 標準ライブラリだけで、引用符つきのCSVを読みたい
自作の読み書きクラス 不要 依存を増やしたくない、書き出しも同じ規則でそろえたい
CsvHelper(未実行) 必要 クラスへの対応づけ、型変換、大量の列

例外の種類と対処(IOException・FileNotFoundException)

ファイルやフォルダが無いとき

Try("ReadAllText(無い)", () => File.ReadAllText("nai.txt"));
Try("ReadAllText(フォルダ無い)", () => File.ReadAllText("nodir/a.txt"));
Try("WriteAllText(フォルダ無い)", () => File.WriteAllText("nodir/a.txt", "x"));
Try("ReadAllText(フォルダ)", () => File.ReadAllText("."));
Try("new StreamReader(無い)", () => new StreamReader("nai.txt"));
Try("ReadAllText(\"\")", () => File.ReadAllText(""));

実行結果(実際のメッセージにはフルパスが入ります。ここでは作業フォルダの部分を省いています):

ReadAllText(無い) => System.IO.FileNotFoundException: Could not find file 'nai.txt'.
ReadAllText(フォルダ無い) => System.IO.DirectoryNotFoundException: Could not find a part of the path 'nodir/a.txt'.
WriteAllText(フォルダ無い) => System.IO.DirectoryNotFoundException: Could not find a part of the path 'nodir/a.txt'.
ReadAllText(フォルダ) => System.UnauthorizedAccessException: Access to the path '(作業フォルダ)' is denied.
new StreamReader(無い) => System.IO.FileNotFoundException: Could not find file 'nai.txt'.
ReadAllText("") => System.ArgumentException: The value cannot be an empty string. (Parameter 'path')
例外 出る場面 IOExceptionの仲間か 確認すること
FileNotFoundException ファイルが無い はい パス、カレントディレクトリ。e.FileName で対象が分かる
DirectoryNotFoundException 途中のフォルダが無い(読み書きとも) はい Directory.CreateDirectory で先に作る
IOException 使用中、ディスクの空き不足など 本体 開いたままの処理が無いか。少し待って再試行
UnauthorizedAccessException 権限が無い、読み取り専用、フォルダをファイルとして開いた いいえ 権限、パスがフォルダを指していないか
ArgumentException パスが空文字など いいえ 呼び出し側の値

継承の関係も実行して確かめました。

FileNotFoundException is IOException: True
DirectoryNotFoundException is IOException: True
UnauthorizedAccessException is IOException: False

catch (IOException) だけでは UnauthorizedAccessException を受け取れません。 また、catchは上から順に判定されるので、FileNotFoundException のような細かい例外を先に、IOException を後に書きます。

使用中のファイルで出るIOException

using (var fs = new FileStream("lock.txt", FileMode.Create, FileAccess.Write, FileShare.None))
{
    fs.Write("abc"u8);
    Try("File.ReadAllText", () => File.ReadAllText("lock.txt"));
    Try("File.AppendAllText", () => File.AppendAllText("lock.txt", "x"));
}
Console.WriteLine("閉じた後: " + File.ReadAllText("lock.txt"));
File.ReadAllText => System.IO.IOException: The process cannot access the file 'lock.txt' because it is being used by another process.
File.AppendAllText => System.IO.IOException: The process cannot access the file 'lock.txt' because it is being used by another process.
閉じた後: abc

メッセージは「別のプロセスが使用中」ですが、この例のように自分のプログラムの中で開いたままのファイルでも同じ例外になります。 まず確認するのは次の3点です。

確認すること 直し方
StreamReader/StreamWriter/FileStream に using を付けているか using var か using (...) { } で囲む
同じファイルを、閉じる前にもう一度開いていないか 先の using を抜けてから次を開く
Excelなど別のソフトで開いていないか 閉じてもらう、または少し待って再試行する

ログのように、書いている最中のファイルを別の処理から読みたいときは、読む側で FileShare.ReadWrite を指定します。

using (var fs = new FileStream("share.log", FileMode.Append, FileAccess.Write, FileShare.Read))
{
    fs.Write("log1\n"u8); fs.Flush();

    // 読む側:相手が書き込み中でも開けるよう FileShare.ReadWrite を指定
    using var rs = new FileStream("share.log", FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
    using var rr = new StreamReader(rs);
    Console.WriteLine("FileShare.ReadWriteで読む: " + rr.ReadToEnd().Trim());
}
FileShare.ReadWriteで読む: log1

共有モードの判定はOSによって細かい違いがあります。今回の結果はLinuxでのもので、Windowsでの動作は未実行です。

少し待って再試行する

他のソフトが一時的に開いているだけなら、少し待って再試行すると読めることがあります。ファイルが無い場合は待っても変わらないので、再試行の対象から外します。

static class Retry
{
    public static async Task<string> ReadAllTextAsync(string path, int maxTry = 5, int waitMs = 200)
    {
        for (int i = 1; ; i++)
        {
            try
            {
                return await File.ReadAllTextAsync(path);
            }
            catch (IOException e) when (e is not FileNotFoundException and not DirectoryNotFoundException && i < maxTry)
            {
                Console.WriteLine($"  {i}回目: {e.GetType().Name} → {waitMs}ms 待って再試行");
                await Task.Delay(waitMs);
            }
        }
    }
}

別の処理が350ミリ秒だけファイルを開いている状態で呼び出した結果です。

  1回目: IOException → 200ms 待って再試行
  2回目: IOException → 200ms 待って再試行
結果: done

when で条件を付けているので、最大回数に達したときは例外がそのまま呼び出し元へ伝わります。

例外をまとめて扱う読み込みメソッド

static class Safe
{
    public static bool TryReadText(string path, out string? text, out string? error)
    {
        text = null; error = null;
        try
        {
            text = File.ReadAllText(path);
            return true;
        }
        catch (FileNotFoundException) { error = $"ファイルがありません: {path}"; }
        catch (DirectoryNotFoundException) { error = $"フォルダがありません: {path}"; }
        catch (UnauthorizedAccessException) { error = $"アクセスする権限がありません: {path}"; }
        catch (IOException e) { error = $"読み込めませんでした: {e.Message}"; }
        return false;
    }

    // 一時ファイルに書いてから置き換える(書きかけの状態を見せない)
    public static void WriteAtomic(string path, string content)
    {
        string tmp = path + ".tmp";
        File.WriteAllText(tmp, content, new UTF8Encoding(false));
        File.Move(tmp, path, overwrite: true);
    }
}
Console.WriteLine(Safe.TryReadText("nai.txt", out var t1, out var err1) ? t1 : "読めません: " + err1);
Safe.WriteAtomic("config.json", "{\"theme\":\"dark\"}");
Console.WriteLine(File.ReadAllText("config.json"));
読めません: ファイルがありません: nai.txt
{"theme":"dark"}

File.Exists で確かめてから読む書き方だけでは、確かめた直後にファイルが消えた場合に例外になります。 「あるはずのファイル」は try〜catch で受け、「無いこともあるファイル」(初回起動時の設定ファイルなど)は File.Exists で分岐、と使い分けると読みやすくなります。File.Move の overwrite 引数は .NET Core 3.0以降で使えます。

つまずきやすい点と直し方

症状 原因 直し方
'shift_jis' is not a supported encoding name .NET Core以降はコードページが既定で入っていない 起動時に Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)
読んだ文字が � になる Shift_JISのファイルをUTF-8として読んだ 文字コードを指定する
Excelで開くと文字化けする BOMなしUTF-8で書いた new UTF8Encoding(true) で書く
先頭の値だけ一致しない BOM(U+FEFF)が先頭に残っている ReadAllText/StreamReader で読む、または先頭を取り除く
「being used by another process」 using の付け忘れ、閉じる前に再度開いた using で囲む、開く順番を見直す
メモリ不足で止まる 大きなファイルを一括で読んだ File.ReadLines か StreamReader
CSVの列がずれる Split(',') で分けた TextFieldParser などを使う
実行場所によってファイルが見つからない 相対パスの基準(カレントディレクトリ)が違う Path.Combine(AppContext.BaseDirectory, "data", "a.txt")

よくある質問

Q. ReadAllText と StreamReader はどちらを使えばよいですか? A. 数MBまでで中身を丸ごと使うなら ReadAllText が手軽です。大きさが読めないファイルは、1行ずつ読む File.ReadLines か StreamReader が向いています。今回の計測では、66MBのファイルで ReadAllText は125.5MBを保持し、1行ずつ読む方法は保持がほぼゼロでした。

Q. Shift_JISを指定したら例外になりました。 A. .NET Core以降では、Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); をアプリの起動時に1回呼ぶと、Encoding.GetEncoding("shift_jis") が使えるようになります。.NET 8のコンソールアプリでは、パッケージの追加なしで動きました。

Q. 非同期版を使うと速くなりますか? A. 今回の計測では、非同期版のほうが時間は長くなりました。非同期の利点は、読み書きを待っている間に画面の操作や他のリクエストを処理できることです。画面のあるアプリやWebアプリでは非同期版、コンソールで順に処理するだけなら同期版、という選び方が合います。

まとめ

  • 小さなファイルは ReadAllText/WriteAllText、大きなファイルは ReadLines か StreamReader で1行ずつ読む
  • 100万行・66MBの計測では、ReadAllTextが125.5MBを保持、1行ずつ読む方法はほぼゼロ。ヒープ上限100MBでは一括読み込みだけが止まった
  • Shift_JISは Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) を起動時に1回呼んでから指定する
  • BOMは、省略すると「なし」、Encoding.UTF8 を渡すと「あり」。new UTF8Encoding(true/false) で明示すると意図が伝わる
  • CSVは Split では崩れる。引用符・カンマ・改行を扱える TextFieldParser などを使う
  • 使用中の IOException は using の付け忘れをまず確認し、他のソフトが原因なら少し待って再試行する
  • UnauthorizedAccessException は IOException の仲間ではないので、catchを分けて書く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#ファイル読み書き|ReadAllText・StreamReader・CSV」

株式会社RJCでは、C#のファイル読み書きや文字コードの扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?