- JAVA
Java CSV読み込み・書き込み入門|文字化けを防ぐBOMと文字コード
JavaでCSVを読み書きする基本は、Files.newBufferedReader と Files.newBufferedWriter に文字コードを明示して渡し、値のエスケープ(カンマ・改行・ダブルクォートの扱い)を RFC 4180 の規則に合わせることです。 Excel で開く CSV は「BOM 付き UTF-8」か「MS932」で出力すると、日本語がそのまま表示されます。
この記事では、読み込みと書き込みを1本にまとめ、すべてのコードを OpenJDK 21.0.10 で実行した出力つきで紹介します。split(",") で列がずれる様子、出力ファイルのバイト列(hexdump)、BOM 付きファイルで先頭の列名が一致しなくなる様子など、つまずきやすい箇所を手元で再現しました。外部ライブラリなしでコピーして動く部品(CsvUtil)も用意しています。
Java CSV読み書きの早見表
| やりたいこと | 書き方(標準API) | ポイント |
|---|---|---|
| CSV を読む | Files.newBufferedReader(path, StandardCharsets.UTF_8) |
文字コードを必ず明示する |
| CSV を書く | Files.newBufferedWriter(path, StandardCharsets.UTF_8) |
try-with-resources で閉じる |
| 追記する | Files.newBufferedWriter(path, cs, StandardOpenOption.CREATE, StandardOpenOption.APPEND) |
見出し行は初回だけ書く |
| 単純な行を分ける | line.split(",", -1) |
-1 を付けると末尾の空項目が残る |
カンマ・改行・" 入りの値を読む |
CsvUtil.readRecord(reader)(本記事の最小パーサ) |
split では列がずれる |
| 値を安全に書く | CsvUtil.escape(value) |
必要なときだけ " で囲み、" は "" にする |
| Excel で開ける UTF-8 を出す | 先頭に writer.write(0xFEFF) |
ファイル先頭が EF BB BF になる |
| Excel 向けに Shift_JIS 系で出す | Charset.forName("MS932") |
正式名は windows-31j |
| BOM 付き CSV を読む | 先頭の1文字が 0xFEFF なら読み飛ばす |
飛ばさないと先頭の列名が一致しない |
| 行を record にする | 見出し行から「列名 → 位置」の Map を作る | 列の並びが変わっても動く |
| 大きなファイルを読む | 1レコードずつ readRecord / readLine |
readAllLines は全行がメモリに載る |
| 行末の改行 | "\r\n"(CRLF) |
RFC 4180 の行末は CRLF |
| 文字コード | Java での指定 | 先頭のバイト | 向いている相手 |
|---|---|---|---|
| UTF-8(BOM なし) | StandardCharsets.UTF_8 |
なし | プログラム同士、データベース、Linux のツール |
| UTF-8(BOM 付き) | StandardCharsets.UTF_8 + 先頭に 0xFEFF |
EF BB BF |
Excel でダブルクリックして開く人 |
| MS932 | Charset.forName("MS932") |
なし | Shift_JIS 系を前提にした既存の仕組み |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java CSV読み込み・書き込み入門|文字化けを防ぐBOMと文字コード」
動作確認の環境と実行方法
環境は OpenJDK 21.0.10(LTS)、Linux(Ubuntu 系コンテナ)、外部ライブラリなしです。
1ファイルで完結する例は java CsvBasic.java のように単一ファイルのまま実行できます。CsvUtil を使う例は2ファイルになるので、Java 21 では次のようにコンパイルしてから実行します。
javac -d out *.java
java -Dstdout.encoding=UTF-8 -cp out CsvRoundTrip
-Dstdout.encoding=UTF-8 は、コンソールに出す文字の文字コードを指定するオプションです(Java 19 以降)。ロケールが C(POSIX)の環境では、付けないと日本語の表示が ? になりました。これは画面表示だけの話で、ファイルに書かれる内容とは別です。ファイルの文字コードは、次の章のとおりコードの中で明示します。
Java 25 での違い(手元では未実行):Java 22 以降は、同じフォルダにある別ファイルのクラスも
java CsvRoundTrip.javaだけで実行できます(JEP 458)。Java 21 で同じことを試すとerror: cannot find symbol ... variable CsvUtilになりました(実行して確認)。また Java 25 ではvoid main() { ... }だけで書ける簡潔なソースファイル(JEP 512)が正式機能になっています。この記事で使うFiles・BufferedReader・Charsetの使い方は Java 21 と 25 で同じです。
CSVを書き込む・読み込む基本形(標準APIだけ)
まずは値にカンマや改行が入らない、いちばん単純な CSV です。書き込みは Files.newBufferedWriter、読み込みは Files.newBufferedReader を使い、どちらにも StandardCharsets.UTF_8 を渡します。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
public class CsvBasic {
public static void main(String[] args) throws IOException {
Path path = Path.of("items.csv");
// 書き込み:try-with-resources で自動的に閉じる
try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
w.write("id,name,price,memo\r\n");
w.write("1,ノート,180,\r\n");
w.write("2,ボールペン,120,黒\r\n");
w.write("3,付箋,250,\r\n");
}
// 読み込み:1行ずつ読んで split で分ける
try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String header = r.readLine();
System.out.println("header = " + header);
String line;
while ((line = r.readLine()) != null) {
String[] a = line.split(","); // 末尾の空項目が消える
String[] b = line.split(",", -1); // 末尾の空項目も残る
System.out.println(a.length + "個 " + Arrays.toString(a)
+ " / " + b.length + "個 " + Arrays.toString(b));
}
}
}
}
実行結果(java CsvBasic.java):
header = id,name,price,memo
3個 [1, ノート, 180] / 4個 [1, ノート, 180, ]
4個 [2, ボールペン, 120, 黒] / 4個 [2, ボールペン, 120, 黒]
3個 [3, 付箋, 250] / 4個 [3, 付箋, 250, ]
split(",") は末尾の空項目を落とす
出力の左側を見ると、memo が空の行は3個になっています。String.split(",") は、末尾に続く空文字列を結果から取り除く仕様だからです。このまま fields[3] を読むと ArrayIndexOutOfBoundsException になります。
| 書き方 | 1,ノート,180, の結果 |
使いどころ |
|---|---|---|
line.split(",") |
3個(末尾の空項目が消える) | 末尾が空にならないとわかっているとき |
line.split(",", -1) |
4個(空項目も残る) | CSV ではこちらを基本にする |
文字コードを省略したときの動き
Files.newBufferedReader(path) のように文字コードを省略すると、Files のメソッドは常に UTF-8 を使います。一方 new FileReader(path) や new FileWriter(path) は「既定の文字コード」を使い、これは Java 18 以降はどの OS でも UTF-8(JEP 400)、Java 17 以前は OS の設定(日本語 Windows なら MS932)でした。
実行する Java の版や OS で結果が変わらないように、読み書きの両方で文字コードを明示するのがおすすめです。
追記するとき
既存のファイルの末尾に行を足すときは、オプションを渡します。
try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
w.write("4,消しゴム,90,\r\n");
}
オプションを付けない newBufferedWriter は、既存の内容を空にしてから書きます。追記のときは見出し行と BOM を2回書かないよう、ファイルが新規かどうかで分けましょう。
split(",") で列がずれる3つのパターン
CSV では、値の中にカンマ・改行・ダブルクォートが入ることがあります。その場合、値全体を " で囲み、値の中の " は "" と2つ重ねる決まりです(RFC 4180)。この形の行を split で分けるとどうなるかを再現します。
import java.util.Arrays;
public class SplitBroken {
public static void main(String[] args) {
String[] lines = {
"1,ノート,180",
"2,\"付箋,大\",250", // 値の中にカンマ
"3,\"彼は\"\"OK\"\"と言った\",0", // 値の中に "
"4,\"1行目", // 値の中に改行(readLine だと2行に分かれる)
"2行目\",90",
};
for (String line : lines) {
String[] f = line.split(",", -1);
System.out.println(f.length + "個 " + Arrays.toString(f));
}
}
}
実行結果:
3個 [1, ノート, 180]
4個 [2, "付箋, 大", 250]
3個 [3, "彼は""OK""と言った", 0]
2個 [4, "1行目]
2個 [2行目", 90]
| 値の中身 | split の結果 | 本来の結果 |
|---|---|---|
カンマ(付箋,大) |
4個に分かれ、" も残る |
3個、値は 付箋,大 |
ダブルクォート(彼は"OK"と言った) |
個数は合うが "" と外側の " が残る |
値は 彼は"OK"と言った |
改行(1行目⏎2行目) |
readLine の時点で2行に分かれる |
1レコード、値は改行を含む |
split が使えるのは、「値にカンマ・改行・" が入らない」と決まっている CSV だけです。人が入力した文章や住所、Excel から保存した CSV を扱うなら、次の章のパーサを使いましょう。
RFC 4180 準拠の最小パーサとエスケープ関数
RFC 4180 は CSV の形式をまとめた文書です。要点は次の5つです。
| 規則 | 内容 |
|---|---|
| レコードの区切り | 改行(CRLF)。最後のレコードの改行はあってもなくてもよい |
| 項目の区切り | カンマ |
| 見出し行 | 1行目に置いてもよい(任意) |
| 囲み | 項目は " で囲んでもよい。カンマ・改行・" を含む項目は必ず囲む |
" のエスケープ |
囲みの中の " は "" と2つ重ねる |
この規則をそのままコードにしたのが、次の CsvUtil です。約70行、標準APIのみで、書き出し用の escape/toLine、読み込み用の readRecord、BOM を飛ばす skipBom を持ちます。
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/** RFC 4180 に沿った最小のCSV読み書き部品(標準APIのみ) */
public final class CsvUtil {
private CsvUtil() {}
/** 1項目を書き出し用に変換する。カンマ・改行・" を含むときだけ " で囲む */
public static String escape(String s) {
if (s == null) return "";
boolean needQuote = s.contains(",") || s.contains("\"")
|| s.contains("\n") || s.contains("\r");
if (!needQuote) return s;
return "\"" + s.replace("\"", "\"\"") + "\"";
}
/** 1レコード分の文字列を作る(行末の改行は含まない) */
public static String toLine(List<String> fields) {
return fields.stream().map(CsvUtil::escape).collect(Collectors.joining(","));
}
/** 先頭の BOM(U+FEFF)があれば読み飛ばす。mark 対応の Reader(BufferedReader)を渡す */
public static void skipBom(Reader in) throws IOException {
in.mark(1);
if (in.read() != '') in.reset();
}
/** 1レコードを読む。項目内の改行にも対応。終端なら null */
public static List<String> readRecord(Reader in) throws IOException {
List<String> fields = new ArrayList<>();
StringBuilder sb = new StringBuilder();
boolean inQuotes = false; // " の内側にいるか
boolean quoted = false; // この項目が " で始まったか
boolean any = false; // 1文字でも読んだか
int c;
while ((c = in.read()) != -1) {
any = true;
char ch = (char) c;
if (inQuotes) {
if (ch == '"') {
in.mark(1);
int next = in.read();
if (next == '"') {
sb.append('"'); // "" は " 1文字
} else {
inQuotes = false; // 閉じる "
if (next != -1) in.reset();
}
} else {
sb.append(ch); // カンマも改行もそのまま値
}
} else if (ch == '"' && sb.length() == 0 && !quoted) {
inQuotes = true;
quoted = true;
} else if (ch == ',') {
fields.add(sb.toString());
sb.setLength(0);
quoted = false;
} else if (ch == '\n') {
fields.add(sb.toString());
return fields;
} else if (ch != '\r') {
sb.append(ch);
}
}
if (!any) return null;
if (inQuotes) throw new IOException("\" が閉じられていません");
fields.add(sb.toString());
return fields;
}
}
読み込みの考え方(状態は「囲みの内か外か」だけ)
readRecord は1文字ずつ読み、いま " の内側にいるかどうかで動きを切り替えます。
内側にいる間は、カンマも改行も「値の一部」として扱うので、列がずれません。" の次の1文字を確認するために mark/reset を使うため、引数には BufferedReader を渡します(Files.newBufferedReader の戻り値でそのまま使えます)。
書いて読み戻す(ラウンドトリップ)
カンマ・"・改行・空文字・前後の空白を含むデータを書き出し、読み戻して元と一致するかを確かめます。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class CsvRoundTrip {
public static void main(String[] args) throws IOException {
Path path = Path.of("tricky.csv");
List<List<String>> rows = List.of(
List.of("id", "name", "memo"),
List.of("1", "ノート", "普通の値"),
List.of("2", "付箋,大", "カンマ入り"),
List.of("3", "彼は\"OK\"と言った", "引用符入り"),
List.of("4", "1行目\n2行目", "改行入り"),
List.of("5", "", " 前後に空白 ")
);
try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
for (List<String> row : rows) {
w.write(CsvUtil.toLine(row));
w.write("\r\n"); // RFC 4180 の行末は CRLF
}
}
System.out.println("--- ファイルの中身 ---");
System.out.print(Files.readString(path, StandardCharsets.UTF_8));
System.out.println("--- 読み戻した結果 ---");
try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
List<String> rec;
int n = 0;
while ((rec = CsvUtil.readRecord(r)) != null) {
System.out.println(rec.size() + "個 " + rec);
if (!rec.equals(rows.get(n++))) throw new AssertionError("不一致: " + rec);
}
System.out.println("全 " + n + " レコードが元のデータと一致");
}
}
}
実行結果:
--- ファイルの中身 ---
id,name,memo
1,ノート,普通の値
2,"付箋,大",カンマ入り
3,"彼は""OK""と言った",引用符入り
4,"1行目
2行目",改行入り
5,, 前後に空白
--- 読み戻した結果 ---
3個 [id, name, memo]
3個 [1, ノート, 普通の値]
3個 [2, 付箋,大, カンマ入り]
3個 [3, 彼は"OK"と言った, 引用符入り]
3個 [4, 1行目
2行目, 改行入り]
3個 [5, , 前後に空白 ]
全 6 レコードが元のデータと一致
どのレコードも3個で、値の中のカンマ・"・改行がそのまま戻っています。必要な項目だけが " で囲まれている点も確認できます。
この最小パーサの範囲
| 項目 | 対応 |
|---|---|
カンマ・改行・"" を含む項目 |
対応 |
| 行末の CRLF/LF | どちらも読める |
| 空の項目・末尾の空項目 | 空文字として残る |
| 空行 | 「空文字1個のレコード」として返る(呼び出し側で読み飛ばす) |
" が閉じていないまま終端 |
IOException で知らせる |
| 区切り文字の変更(タブ区切りなど) | 非対応(',' の箇所を書き換える) |
| 空文字と null の区別 | 非対応(どちらも空文字) |
区切り文字を切り替えたい、列数の検査をしたい、Bean へ自動で変換したい、といった要件が増えてきたら、後半のライブラリ比較表を参考にしてください。
Excelで文字化けしないCSVを出す(BOM付きUTF-8とMS932)
日本語 Windows の Excel は、CSV をダブルクリックで開くとき、先頭に BOM があれば UTF-8、なければ Shift_JIS 系(MS932)として読む動きをします。そのため BOM なしの UTF-8 を渡すと日本語が別の文字に見えます(Excel での表示そのものは手元では未確認で、Microsoft の案内に基づく説明です。出力したバイト列は下で確認しています)。
BOM(Byte Order Mark)は、ファイルの先頭に置く目印の文字 U+FEFF です。UTF-8 で書くと EF BB BF の3バイトになります。Java では、最初に writer.write(0xFEFF) と1文字書くだけです。
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class CsvEncoding {
static final String DATA = "id,品名\r\n1,ノート\r\n";
public static void main(String[] args) throws IOException {
// (1) UTF-8(BOMなし)
try (BufferedWriter w = Files.newBufferedWriter(Path.of("utf8.csv"), StandardCharsets.UTF_8)) {
w.write(DATA);
}
// (2) UTF-8(BOM付き):先頭に U+FEFF を1文字書くだけ
try (BufferedWriter w = Files.newBufferedWriter(Path.of("utf8bom.csv"), StandardCharsets.UTF_8)) {
w.write('');
w.write(DATA);
}
// (3) MS932(Windows の Shift_JIS 拡張。別名 windows-31j)
try (BufferedWriter w = Files.newBufferedWriter(Path.of("ms932.csv"), Charset.forName("MS932"))) {
w.write(DATA);
}
for (String name : new String[] {"utf8.csv", "utf8bom.csv", "ms932.csv"}) {
System.out.println(name + " = " + Files.size(Path.of(name)) + " バイト");
}
System.out.println("既定の文字コード = " + Charset.defaultCharset()
+ " / MS932 の正式名 = " + Charset.forName("MS932"));
}
}
実行結果:
utf8.csv = 24 バイト
utf8bom.csv = 27 バイト
ms932.csv = 19 バイト
既定の文字コード = UTF-8 / MS932 の正式名 = windows-31j
hexdump でバイト列を確認する
$ hexdump -C utf8.csv
00000000 69 64 2c e5 93 81 e5 90 8d 0d 0a 31 2c e3 83 8e |id,........1,...|
00000010 e3 83 bc e3 83 88 0d 0a |........|
00000018
$ hexdump -C utf8bom.csv
00000000 ef bb bf 69 64 2c e5 93 81 e5 90 8d 0d 0a 31 2c |...id,........1,|
00000010 e3 83 8e e3 83 bc e3 83 88 0d 0a |...........|
0000001b
$ hexdump -C ms932.csv
00000000 69 64 2c 95 69 96 bc 0d 0a 31 2c 83 6d 81 5b 83 |id,.i....1,.m.[.|
00000010 67 0d 0a |g..|
00000013
$ file utf8.csv utf8bom.csv ms932.csv
utf8.csv: CSV Unicode text, UTF-8 text
utf8bom.csv: CSV Unicode text, UTF-8 (with BOM) text
ms932.csv: CSV Non-ISO extended-ASCII text
| ファイル | サイズ | 読み取れること |
|---|---|---|
utf8.csv |
24 バイト | 「品」が e5 93 81 の3バイト。先頭は 69 64(id) |
utf8bom.csv |
27 バイト | 先頭に ef bb bf の3バイトが増えただけで、残りは同じ |
ms932.csv |
19 バイト | 「品」が 95 69 の2バイト。BOM はない |
0d 0a は行末の CRLF です。BOM の有無は見た目ではわからないので、hexdump -C ファイル | head -1 や file コマンドで確かめるのが確実です。
どの文字コードで出すかの判断表
| 受け取る相手 | おすすめ | 理由 |
|---|---|---|
| 別のプログラム・データベースの取り込み | UTF-8(BOM なし) | BOM を値の一部として読む処理系があるため |
| Excel でダブルクリックして開く人 | UTF-8(BOM 付き) | すべての文字を表せて、Excel が UTF-8 と判定できる |
| Shift_JIS 系のみ受け付ける仕組み | MS932 | 相手の仕様に合わせる。表せない文字に気を配る |
| 相手がわからない | 画面で選べるようにする | 「Excel 用」「システム連携用」の2種類を用意する |
MS932 で表せない文字を書くとどうなるか
MS932 は収録文字が限られます。たとえば波ダッシュ「〜」(U+301C)や「♥」は、Java の MS932 では表せません(見た目が似た全角チルダ「~」U+FF5E は表せます)。書き方によって動きが変わるので、実行して比べました。
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
public class Ms932Unmappable {
public static void main(String[] args) throws IOException {
Charset ms932 = Charset.forName("MS932");
String text = "1,10〜20個,♥,①髙\r\n"; // 〜(U+301C) と ♥ は MS932 にない。① と 髙 はある
// (A) Files.newBufferedWriter:表せない文字があると例外で知らせる
try (BufferedWriter w = Files.newBufferedWriter(Path.of("a.csv"), ms932)) {
w.write(text);
} catch (IOException e) {
System.out.println("(A) " + e.getClass().getName() + ": " + e.getMessage());
}
// (B) String.getBytes:表せない文字は ? に置き換わる
byte[] b = text.getBytes(ms932);
System.out.println("(B) " + new String(b, ms932).trim());
// (C) 書く前に調べる
CharsetEncoder enc = ms932.newEncoder();
System.out.println("(C) canEncode(〜)=" + enc.canEncode("〜")
+ " canEncode(~)=" + enc.canEncode("~")
+ " canEncode(①髙)=" + enc.canEncode("①髙"));
}
}
実行結果:
(A) java.nio.charset.UnmappableCharacterException: Input length = 1
(B) 1,10?20個,?,①髙
(C) canEncode(〜)=false canEncode(~)=true canEncode(①髙)=true
| 書き方 | 表せない文字があるとき |
|---|---|
Files.newBufferedWriter(path, ms932) |
UnmappableCharacterException で知らせる。ファイルは途中まで書かれた状態で残る(今回は 1,10 の4バイト) |
text.getBytes(ms932) |
例外にならず ? に置き換わる |
new OutputStreamWriter(out, "MS932") |
例外にならず ? に置き換わる(Javadoc の説明による。この行は未実行) |
encoder.canEncode(text) |
書く前に調べられる |
気づかないうちに ? になるより、例外で気づけるほうが安全です。MS932 で出すときは、Files.newBufferedWriter を使ったうえで、(C) のように事前に調べて利用者へ知らせるか、一時ファイルに書いてから成功時に置き換える形にすると、途中までのファイルが残りません。
BOM付きCSVを読むと先頭の列名が一致しない
Excel の「CSV UTF-8(コンマ区切り)」で保存したファイルには BOM が付きます。Java の UTF-8 デコーダは BOM を取り除かず、U+FEFF という1文字として読み込みます。その結果、先頭の列名が id ではなく「見えない1文字+id」になります。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class BomRead {
public static void main(String[] args) throws IOException {
Path path = Path.of("utf8bom.csv"); // CsvEncoding で作った BOM 付きファイル
for (boolean skip : new boolean[] {false, true}) {
System.out.println(skip ? "--- BOM を読み飛ばしてから読む ---" : "--- そのまま読む ---");
try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
if (skip) CsvUtil.skipBom(r);
List<String> header = CsvUtil.readRecord(r);
String first = header.get(0);
String visible = first.replace(String.valueOf((char) 0xFEFF), "<BOM>");
System.out.println("先頭列名 = [" + visible + "] 長さ = " + first.length());
System.out.println("\"id\".equals(先頭列名) = " + "id".equals(first));
System.out.println("indexOf(\"id\") = " + header.indexOf("id"));
}
}
}
}
実行結果:
--- そのまま読む ---
先頭列名 = [<BOM>id] 長さ = 3
"id".equals(先頭列名) = false
indexOf("id") = -1
--- BOM を読み飛ばしてから読む ---
先頭列名 = [id] 長さ = 2
"id".equals(先頭列名) = true
indexOf("id") = 0
(U+FEFF は画面に表示されない文字なので、この例では <BOM> に置き換えて出力しています。)
| 症状 | 原因 | 対処 |
|---|---|---|
画面には id と出るのに equals("id") が false |
先頭に U+FEFF が付いている | CsvUtil.skipBom(reader) を最初に呼ぶ |
map.get("id") が null になる |
同上(キーが3文字になっている) | 同上 |
見出し行がないファイルで Integer.parseInt が NumberFormatException |
1件目の先頭の値に U+FEFF が付いている | 同上 |
| 先頭列だけおかしい | BOM はファイルの先頭にしかない | 2列目以降が正常なら BOM を疑う |
skipBom は BOM がないファイルに使っても何も起きないので、UTF-8 の CSV を読むときは常に呼んでおくと安心です。
CSVの1行を record に変換する
読み込んだ List<String> を、そのまま使い続けると「3番目は何だったか」がわかりにくくなります。record にすると、型と名前が付いて扱いやすくなります。 見出し行から「列名 → 位置」の対応表を作っておくと、列の並びが変わっても同じコードで読めます。
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class CsvToRecord {
/** CSV の1行に対応する record */
record Item(int id, String name, int price, LocalDate addedOn) {
static Item from(List<String> f, Map<String, Integer> col) {
return new Item(
Integer.parseInt(f.get(col.get("id")).trim()),
f.get(col.get("name")),
Integer.parseInt(f.get(col.get("price")).trim()),
LocalDate.parse(f.get(col.get("added_on")).trim()));
}
}
public static void main(String[] args) throws IOException {
Path in = Path.of("stock.csv");
Files.writeString(in, """
name,id,added_on,price
ノート,1,2026-09-01,180
"付箋,大",2,2026-09-15,250
消しゴム,3,2026-10-01,abc
ボールペン,4,2026-10-02,120
""", StandardCharsets.UTF_8);
List<Item> items = new ArrayList<>();
try (BufferedReader r = Files.newBufferedReader(in, StandardCharsets.UTF_8)) {
CsvUtil.skipBom(r);
// 見出し行から「列名 → 位置」の対応表を作る(列の並びが変わっても動く)
List<String> header = CsvUtil.readRecord(r);
Map<String, Integer> col = new HashMap<>();
for (int i = 0; i < header.size(); i++) col.put(header.get(i).trim(), i);
List<String> rec;
int lineNo = 1;
while ((rec = CsvUtil.readRecord(r)) != null) {
lineNo++;
if (rec.size() == 1 && rec.get(0).isEmpty()) continue; // 空行は飛ばす
try {
items.add(Item.from(rec, col));
} catch (RuntimeException e) {
System.out.println(lineNo + " レコード目を読み飛ばし: " + e);
}
}
}
items.forEach(System.out::println);
int total = items.stream().mapToInt(Item::price).sum();
System.out.println("件数 = " + items.size() + " / 合計 = " + total);
}
}
実行結果:
4 レコード目を読み飛ばし: java.lang.NumberFormatException: For input string: "abc"
Item[id=1, name=ノート, price=180, addedOn=2026-09-01]
Item[id=2, name=付箋,大, price=250, addedOn=2026-09-15]
Item[id=4, name=ボールペン, price=120, addedOn=2026-10-02]
件数 = 3 / 合計 = 550
入力の列順(name,id,added_on,price)が record の順と違っても、列名で引いているので正しく読めています。数値に変換できない行は、何レコード目かを添えて記録し、残りの処理を続けています。
変換でつまずきやすい点
| つまずき | 起きること | 対処 |
|---|---|---|
数値の前後に空白(180) |
NumberFormatException: For input string: " 180" |
trim() してから変換する |
| 空の項目を数値にする | NumberFormatException: For input string: "" |
空なら既定値や null(Integer)にする分岐を入れる |
日付が 2026/10/02 形式 |
DateTimeParseException |
DateTimeFormatter.ofPattern("uuuu/MM/dd") を渡す |
| 行ごとに列数が違う | IndexOutOfBoundsException |
rec.size() == header.size() を確認する |
大きなCSVは1行ずつ読む
Files.readAllLines は便利ですが、全行を List<String> としてメモリに載せます。大きなファイルでは、1レコードずつ読んで処理し、読み終えたものは手放す形にします。200万行(49 MB)の CSV を作り、ヒープを 64 MB に絞って比べました。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class BigCsv {
public static void main(String[] args) throws IOException {
Path path = Path.of("big.csv");
if (!Files.exists(path)) {
try (BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
w.write("id,name,price\r\n");
for (int i = 1; i <= 2_000_000; i++) {
w.write(i + ",商品" + i + "," + (i % 1000) + "\r\n");
}
}
}
System.out.println("サイズ = " + Files.size(path) / 1024 / 1024 + " MB / 最大ヒープ = "
+ Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB");
long t0 = System.nanoTime();
long count = 0, sum = 0;
if (args.length > 0 && args[0].equals("all")) {
// まとめて読む:全行がメモリに載る
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
for (String line : lines.subList(1, lines.size())) {
sum += Long.parseLong(line.split(",", -1)[2]);
count++;
}
} else {
// 1レコードずつ読む:メモリに載るのは今の1件だけ
try (BufferedReader r = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
CsvUtil.skipBom(r);
CsvUtil.readRecord(r); // 見出し行
List<String> rec;
while ((rec = CsvUtil.readRecord(r)) != null) {
sum += Long.parseLong(rec.get(2));
count++;
}
}
}
System.out.printf("件数 = %,d / 合計 = %,d / %d ms%n",
count, sum, (System.nanoTime() - t0) / 1_000_000);
}
}
実行結果:
$ java -Xmx64m -cp out BigCsv
サイズ = 49 MB / 最大ヒープ = 64 MB
件数 = 2,000,000 / 合計 = 999,000,000 / 2324 ms
$ java -Xmx64m -cp out BigCsv all
サイズ = 49 MB / 最大ヒープ = 64 MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.StringUTF16.compress(StringUTF16.java:211)
at java.base/java.lang.String.<init>(String.java:4760)
$ java -cp out BigCsv all
サイズ = 49 MB / 最大ヒープ = 1496 MB
件数 = 2,000,000 / 合計 = 999,000,000 / 3162 ms
(時間は1回の計測値で、環境によって変わります。)
| 方法 | 向いている場面 | 気を付けること |
|---|---|---|
readRecord/readLine のループ |
サイズが読めないファイル、集計や1件ずつの登録 | 読んだ結果を全部 List にためると同じことになる |
Files.lines(path, cs) |
Stream で絞り込み・集計したいとき | try-with-resources で閉じる。値の中の改行には対応しない |
Files.readAllLines |
数千行程度の小さな設定・マスタ | 行数の上限が決まっているときに使う |
書き込みも同じ考え方で、全行を1つの文字列に連結してから書くのではなく、1レコードごとに writer.write すると、件数が増えてもメモリの使用量が一定です。
ライブラリを使う場合の比較(OpenCSV・Apache Commons CSV・Jackson CSV)
要件が増えてきたら、広く使われているライブラリが頼りになります。この章の内容は各ライブラリの公式サイトで確認したもので、手元の環境は Maven Central に接続できないため、ライブラリ自体は動かしていません(未実行)。
| OpenCSV | Apache Commons CSV | Jackson CSV | |
|---|---|---|---|
| Maven の座標 | com.opencsv:opencsv |
org.apache.commons:commons-csv |
com.fasterxml.jackson.dataformat:jackson-dataformat-csv |
| 公式で確認できた版 | 5.12.0(2025-07-26 公開) | 1.14 系(サイトの最終公開 2025-07-30) | Jackson 本体と同じ番号で公開 |
| 必要な Java | 8 以上 | 8 以上 | Jackson 本体の版に従う |
| 読み方の中心 | CSVReader、CsvToBeanBuilder |
CSVFormat、CSVParser、CSVRecord |
CsvMapper、CsvSchema |
| クラスへの変換 | アノテーション(@CsvBindByName など) |
なし(record.get("列名") で取り出す) |
Jackson のデータバインド |
| 得意なこと | Bean との相互変換を少ない記述で | 形式の細かな指定(Excel 形式・タブ区切りなど) | JSON と同じ感覚で扱える、既に Jackson を使っている構成 |
| BOM | 自分で読み飛ばす | Commons IO の BOMInputStream と組み合わせる例が多い |
自分で読み飛ばす |
自作かライブラリかの判断表
| 状況 | 選び方 |
|---|---|
| 依存を増やせない小さなツール、学習 | 本記事の CsvUtil(標準APIのみ) |
| 値にカンマ・改行が入らないと決まっている | split(",", -1) でも足りる |
| 区切り文字や囲み文字を切り替えたい | Apache Commons CSV |
| クラスとの相互変換を宣言的に書きたい | OpenCSV |
| すでに Jackson を使っている | Jackson CSV |
| 文字コードと BOM | どれを使っても、Reader/Writer を作るときに自分で指定する |
ライブラリを使う場合も、文字コードの明示・BOM の扱い・1件ずつ処理するという考え方はこの記事と同じです。
よくある質問
Q. BufferedWriter と PrintWriter はどちらを使えばよいですか?
A. どちらでも書けます。PrintWriter の println は、行末が OS の改行(Linux なら LF、Windows なら CRLF)になり、書き込みエラーを例外で知らせない仕様です。CSV では行末を "\r\n" と明示し、エラーに気づけるよう BufferedWriter の write を使う形が扱いやすいです。
Q. Shift_JIS と MS932 は同じですか?
A. 別の文字コードとして用意されています。MS932(正式名 windows-31j)は Windows で使われる拡張版で、「①」「髙」などを含みます。日本語 Windows の Excel とやり取りする CSV は MS932 を指定すると、これらの文字もそのまま扱えます。
Q. 受け取った CSV の文字コードがわかりません。どう調べますか?
A. まず hexdump -C ファイル | head -1 で先頭を見ます。ef bb bf なら BOM 付き UTF-8 です。BOM がない場合は file コマンドの判定を参考にし、最終的には提供元に確認するのが確実です。UTF-8 として CharsetDecoder で厳密に読んでみて、例外になれば MS932 を試す、という順で判定する方法もあります。
まとめ
- 読み書きは
Files.newBufferedReader/newBufferedWriterに文字コードを明示して、try-with-resources で閉じる split(",")は末尾の空項目が消え、カンマ・改行・"入りの値で列がずれる。単純な CSV でもsplit(",", -1)を使う- RFC 4180 の規則(必要なら
"で囲み、"は"")は約70行のパーサとエスケープ関数で実装できる - Excel 向けは BOM 付き UTF-8(先頭に
0xFEFFを1文字)か MS932。出力はhexdump -Cでef bb bfを確認する - BOM 付き CSV を読むと先頭の列名に U+FEFF が付くので、最初に読み飛ばす
- MS932 で表せない文字は、
Files.newBufferedWriterなら例外、getBytesなら?になる。canEncodeで事前に調べられる - 大きなファイルは1レコードずつ読む。要件が増えたら OpenCSV・Apache Commons CSV・Jackson CSV を検討する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java CSV読み込み・書き込み入門|文字化けを防ぐBOMと文字コード」
株式会社RJCでは、JavaでのCSVの読み書きや文字コードの扱いを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。