Skip to content
CalliCoder

Read and Write CSV in Java with Commons CSV

Published Updated Java 13 min read

Why splitting on commas fails on real data, the format presets that handle quoting and embedded newlines, header access by name, and the flush that decides whether your file has content.

CSV looks like a format you can parse with split(","), and that works until a field contains a comma, a quote, or a newline: all of which are legal and all of which appear in exported data. Commons CSV exists because the edge cases are the format.

Written against Apache Commons CSV 1.10 and Java 17.

Why not split on commas

id,name,notes
1,"Smith, John","He said ""hello""
and left"
2,Jane,

Three rows of CSV, and split(",") gets all of them wrong. Row 1 has a comma inside a quoted field. It also has an escaped quote (doubled, which is how CSV escapes) and a newline inside a field, so the record spans two lines. Row 2 has an empty trailing field.

Any parser handling those correctly is a small state machine, and writing one is not a good use of an afternoon.

The dependency

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.10.0</version>
</dependency>

Reading with a header

CSVFormat format = CSVFormat.DEFAULT.builder()
        .setHeader()                  // read the first record as the header
        .setSkipHeaderRecord(true)
        .setIgnoreSurroundingSpaces(true)
        .setTrim(true)
        .build();

try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8);
     CSVParser parser = CSVParser.parse(reader, format)) {

    for (CSVRecord record : parser) {
        String id = record.get("id");
        String name = record.get("name");
        System.out.println(id + " -> " + name);
    }
}

setHeader() with no arguments reads the header from the file, and setSkipHeaderRecord(true) stops it being returned as data. Setting the first without the second gives you a first row where every value equals its own column name, a confusing off-by-one that looks like a data problem.

Access by name rather than by index. A column inserted upstream shifts every index and breaks the code silently; a name either resolves or throws IllegalArgumentException naming the column.

To declare the header instead of reading it, for a file that has none:

CSVFormat.DEFAULT.builder()
        .setHeader("id", "name", "notes")
        .setSkipHeaderRecord(false)
        .build();

Reading by index

try (CSVParser parser = CSVParser.parse(reader, CSVFormat.DEFAULT)) {
    for (CSVRecord record : parser) {
        String first = record.get(0);
        long line = record.getRecordNumber();
    }
}

getRecordNumber() is the record count, not the line number, they differ once a field contains a newline. For error messages, parser.getCurrentLineNumber() is the one that matches what a person sees in a text editor.

The format presets

CSVFormat.DEFAULT is RFC 4180 with a comma. The others exist because “CSV” is not one format:

PresetDelimiterNotes
DEFAULT,RFC 4180, CRLF line endings
EXCEL,tolerates empty lines; Excel’s own dialect
TDFtabtab-delimited
RFC4180,strict
MYSQLtabbackslash escaping, \N for null
POSTGRESQL_CSV,the COPY ... CSV dialect

Two settings are worth knowing regardless of preset:

CSVFormat.DEFAULT.builder()
        .setDelimiter(';')            // common in locales where comma is the decimal separator
        .setQuote('"')
        .setNullString("")            // read empty fields as null rather than ""
        .setAllowMissingColumnNames(false)
        .build();

The semicolon case is not exotic — a spreadsheet saved as CSV in Germany or France uses one, because the comma is the decimal separator. Detecting the delimiter by counting candidates in the first line is a reasonable heuristic and should still be overridable by the caller.

Writing

CSVFormat format = CSVFormat.DEFAULT.builder()
        .setHeader("id", "name", "notes")
        .build();

try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8);
     CSVPrinter printer = new CSVPrinter(writer, format)) {

    printer.printRecord(1, "Smith, John", "quoted \"text\"");
    printer.printRecord(2, "Jane", null);

    for (Article article : articles) {
        printer.printRecord(article.id(), article.title(), article.publishedAt());
    }
}

The printer quotes whatever needs quoting — the comma in the first name, the embedded quotes in the notes — using the minimal-quoting rule. QuoteMode.ALL quotes everything if a downstream consumer demands it.

The try-with-resources is doing real work here. CSVPrinter buffers, and without a close or an explicit flush() the file ends up short or empty. A CSV that is missing its last few rows is almost always a missing flush.

Passing null writes an empty field. setNullString("\\N") changes that, which is what a MySQL import expects.

Charset and the BOM

Always name the charset. The bigger practical problem is Excel: opening a UTF-8 CSV without a byte order mark, it decodes as the system’s legacy encoding and accented characters break.

Writing a BOM makes Excel read it correctly and makes most other parsers see three junk bytes in the first field name:

writer.write('');   // only if Excel is the consumer

Reading is the mirror problem — a file that starts with a BOM produces a first column named id, which is why record.get("id") throws on a file that looks perfectly normal. Strip it, or use BOMInputStream from Commons IO.

Large files

The parser is a streaming iterator, so a file larger than memory is fine as long as you do not collect it:

try (CSVParser parser = CSVParser.parse(reader, format)) {
    parser.stream()
          .filter(r -> !r.get("status").equals("deleted"))
          .forEach(this::process);
}

parser.getRecords() reads everything into a List and is the call to avoid on anything large.

CSVRecord objects are also not safe to hold onto in bulk — each one keeps a reference to the header map, so collecting a million of them is heavier than collecting a million small records of your own. Convert inside the loop and keep the result, not the record.

Writing large output has the mirror concern. CSVPrinter flushes as its buffer fills, so memory is bounded, but wrapping the writer in a BufferedWriter still matters: without it, every field becomes its own small write to the underlying stream.

Handling bad rows without stopping

Real CSV arrives with rows that do not parse: a missing column, a number in a date field, a truncated final line. Letting the first one abort a hundred-thousand-row import is rarely what anyone wants, and neither is silently skipping it.

The shape that works is to collect failures alongside successes:

record ImportResult(List<Article> imported, List<String> errors) { }

ImportResult read(Path path) throws IOException {
    List<Article> ok = new ArrayList<>();
    List<String> errors = new ArrayList<>();

    try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8);
         CSVParser parser = CSVParser.parse(reader, format)) {

        for (CSVRecord record : parser) {
            try {
                ok.add(toArticle(record));
            } catch (RuntimeException e) {
                errors.add("line " + record.getRecordNumber() + ": " + e.getMessage());
            }
        }
    }
    return new ImportResult(ok, errors);
}

Catching RuntimeException rather than a specific type is deliberate here: the conversion can throw NumberFormatException, DateTimeParseException or IllegalArgumentException depending on which field is wrong, and the handling is identical for all of them.

Two refinements worth adding. record.isConsistent() reports whether the row has the same number of fields as the header, which catches a shifted row before any conversion runs. And a cap on the error list stops a completely malformed file from producing a million strings.

Whether a partial import should commit is a decision to make explicitly rather than by default. Both answers are defensible; a silent partial import is not.

Mapping to objects

Commons CSV has no object mapping — it gives you records and you write the conversion:

static Article toArticle(CSVRecord record) {
    return new Article(
            Long.parseLong(record.get("id")),
            record.get("title"),
            LocalDate.parse(record.get("published_at")));
}

That is more code than an annotation-driven mapper and more control over bad input, which in CSV is the common case. OpenCSV takes the annotation approach if you prefer it.

More in the Java guides.

Frequently asked questions

Why not just split on commas?

Fields can contain commas, doubled quotes and newlines, all legal CSV. A correct parser is a state machine, and a split gets the first quoted comma wrong.

How do I read a CSV with a header row?

setHeader() with no arguments plus setSkipHeaderRecord(true), then record.get("columnName"). Omitting the second returns the header as a data row.

Should I access fields by name or index?

By name. An inserted column shifts every index silently; a name either resolves or throws with the column named.

How do I handle a semicolon-delimited file?

setDelimiter(';'). Spreadsheets saved in locales where the comma is the decimal separator use semicolons by default.

Why is my output file empty or truncated?

The printer was not closed or flushed. Use try-with-resources, which closes the printer and the underlying writer.

How do I write a null value?

Passing null writes an empty field. setNullString changes the representation — \N for a MySQL import, for example.

Why does Excel show broken characters?

It does not assume UTF-8. Write a BOM if Excel is the consumer, and be aware that other parsers then see it as part of the first field.

Why does record.get(“id”) throw on a valid-looking file?

The file starts with a BOM, so the first column is named id. Strip it on read.

Can I stream a file larger than memory?

Yes — CSVParser is an iterator, and parser.stream() works lazily. Avoid getRecords(), which materialises everything.

Does Commons CSV map rows to objects?

No. It gives you CSVRecord and you write the conversion. OpenCSV offers annotation-based binding if that is what you want.