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期連続の黒字決算。

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

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