Skip to content
CalliCoder

Read and Write CSV in Java with OpenCSV

Java 13 min read

Annotation-driven binding to objects, the bean strategy that maps by header name instead of position, why the default reader is lenient about broken quoting, and the injection guard on the writer.

OpenCSV’s distinguishing feature is that it maps rows to objects for you. Where Commons CSV hands back a record and leaves the conversion to your code, OpenCSV reads annotations and produces a list of beans, which is less code and a different set of failure modes.

Written against OpenCSV 5.9 and Java 17.

The dependency

<dependency>
    <groupId>com.opencsv</groupId>
    <artifactId>opencsv</artifactId>
    <version>5.9</version>
</dependency>

Reading into a String array

The lowest-level API, when there is no bean to map to:

try (CSVReader reader = new CSVReader(Files.newBufferedReader(path, StandardCharsets.UTF_8))) {
    String[] line;
    while ((line = reader.readNext()) != null) {
        System.out.println(line[0] + " -> " + line[1]);
    }
}

readNext() returns null at the end, which makes the loop idiom work. readAll() exists and reads everything into memory, which is the call to avoid on a large file.

reader.skip(1) discards the header. Forgetting it means processing the column names as data: an error that produces one wrong row rather than an exception.

Reading into objects, by name

public class ArticleCsv {

    @CsvBindByName(column = "id", required = true)
    private long id;

    @CsvBindByName(column = "title", required = true)
    private String title;

    @CsvDate("yyyy-MM-dd")
    @CsvBindByName(column = "published_at")
    private LocalDate publishedAt;

    // no-arg constructor, getters and setters
}
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    List<ArticleCsv> articles = new CsvToBeanBuilder<ArticleCsv>(reader)
            .withType(ArticleCsv.class)
            .withIgnoreLeadingWhiteSpace(true)
            .build()
            .parse();
}

@CsvBindByName maps by header name, so a column inserted upstream does not break anything. That is the strategy to prefer.

The bean needs a public no-argument constructor and setters, OpenCSV instantiates and populates reflectively. A record will not work, which is the main friction with modern Java: the mapping target has to be a mutable class, so records belong on the other side of a conversion step.

@CsvDate is required for any temporal type; without it OpenCSV cannot convert the string and fails with a message about the field type rather than the format.

Reading by position

@CsvBindByPosition(position = 0)
private long id;

@CsvBindByPosition(position = 1)
private String title;

For a file with no header. Do not mix the two annotation families in one class: OpenCSV picks a strategy from the annotations it finds, and a class carrying both binds unpredictably.

Streaming instead of collecting

parse() materialises the whole file. For a large one, iterate:

CsvToBean<ArticleCsv> csvToBean = new CsvToBeanBuilder<ArticleCsv>(reader)
        .withType(ArticleCsv.class)
        .build();

for (ArticleCsv article : csvToBean) {
    process(article);
}

CsvToBean implements Iterable, and the iterator parses lazily. csvToBean.stream() is the same thing as a stream.

The reader is lenient by default

This is the behaviour to know about before trusting the output. CSVReader uses CSVParser, which by default accepts input RFC 4180 would reject (an unterminated quote, a quote in the middle of an unquoted field) and produces a best-effort result rather than an error.

Strictness is opt-in:

CSVParser parser = new CSVParserBuilder()
        .withSeparator(',')
        .withQuoteChar('"')
        .withStrictQuotes(false)
        .withIgnoreQuotations(false)
        .build();

CSVReader reader = new CSVReaderBuilder(fileReader)
        .withCSVParser(parser)
        .withSkipLines(1)
        .build();

RFC4180Parser is the alternative implementation that follows the specification exactly:

CSVReader reader = new CSVReaderBuilder(fileReader)
        .withCSVParser(new RFC4180ParserBuilder().build())
        .build();

Use it when the input is machine-generated and a malformed file should be an error. Use the default lenient parser when the input is whatever a spreadsheet produced and getting most of it is better than getting none.

Writing

try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8);
     CSVWriter csvWriter = new CSVWriter(writer)) {

    csvWriter.writeNext(new String[] { "id", "title", "published_at" });
    csvWriter.writeNext(new String[] { "1", "Smith, John", "2026-08-25" });
}

CSVWriter quotes every field by default, which is valid CSV and larger than it needs to be. To quote only where necessary:

csvWriter.writeNext(row, false);   // applyQuotesToAll = false

Writing beans back out uses the same annotations:

StatefulBeanToCsv<ArticleCsv> beanToCsv = new StatefulBeanToCsvBuilder<ArticleCsv>(writer)
        .withApplyQuotesToAll(false)
        .build();

beanToCsv.write(articles);

The column order for a @CsvBindByName class is alphabetical by default, not declaration order — surprising the first time, and fixed by supplying a HeaderColumnNameMappingStrategy with an explicit column ordering.

As with any buffered writer, close it. An unclosed CSVWriter produces a truncated file, and checkError() is the way to detect a write failure, because writeNext does not throw.

CSV injection

A field beginning with =, +, - or @ is interpreted as a formula when the file is opened in a spreadsheet, so exported user input can execute on the machine of whoever opens it.

new StatefulBeanToCsvBuilder<ArticleCsv>(writer)
        .withApplyQuotesToAll(true)
        .build();

Quoting alone does not stop it, Excel evaluates a quoted formula too. The reliable defence is to prefix a leading =, +, - or @ with a single quote, or to reject such values on input. Any export containing text a user typed needs one of the two.

Validating and converting during the bind

Two annotations do work that would otherwise be a second pass over the parsed beans.

@PreAssignmentValidator runs before a value is set and rejects the row if it fails:

@PreAssignmentValidator(validator = MustMatchRegexExpression.class, paramString = "^[A-Z]{2}-\\d{4}$")
@CsvBindByName(column = "reference")
private String reference;

@CsvCustomBindByName handles a type the built-in converters do not know:

@CsvCustomBindByName(column = "status", converter = StatusConverter.class)
private Status status;
public class StatusConverter extends AbstractBeanField<Status, String> {
    @Override
    protected Status convert(String value) throws CsvConstraintViolationException {
        try {
            return Status.valueOf(value.toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new CsvConstraintViolationException("Unknown status: " + value);
        }
    }
}

The failure handling is the part worth planning. By default a conversion error aborts the whole parse with a CsvException. withThrowExceptions(false) collects them instead, and csvToBean.getCapturedExceptions() returns them after the parse with the line number attached:

CsvToBean<ArticleCsv> csvToBean = new CsvToBeanBuilder<ArticleCsv>(reader)
        .withType(ArticleCsv.class)
        .withThrowExceptions(false)
        .build();

List<ArticleCsv> good = csvToBean.parse();
List<CsvException> bad = csvToBean.getCapturedExceptions();

Note the ordering trap: getCapturedExceptions() is only populated after the parse has run, so calling it before parse() returns an empty list and makes a broken file look clean.

Separators, encoding and the spreadsheet problem

CSVParser parser = new CSVParserBuilder()
        .withSeparator(';')
        .build();

A semicolon rather than a comma is the norm for spreadsheets saved in locales where the comma is the decimal separator, so any import accepting user-supplied files needs it configurable.

The encoding question is the same one every CSV library has and none can solve: the format carries no declaration of its own. Name the charset when opening the reader, and expect UTF-8 files exported for Excel to begin with a byte order mark, which becomes part of the first column’s name and makes binding by that name fail on a file that looks correct in an editor. BOMInputStream from Commons IO strips it, or withSkipLines sidesteps it when binding by position.

On output the mirror decision: writing a BOM makes Excel decode UTF-8 correctly and makes most other parsers see three stray bytes. Which is right depends entirely on who opens the file, so it belongs in configuration rather than in the export code.

Which library

OpenCSV for annotation-driven binding and a lenient reader; Commons CSV for explicit control, format presets and a strict parser by default. Both stream, both handle quoting correctly on output, and neither is meaningfully faster.

The practical deciding factor is usually the target type. If the destination is already a mutable class with setters, OpenCSV removes a layer of code. If the destination is a record, or the conversion needs to reject rows on business rules rather than on types, the explicit loop is clearer than an annotation plus a custom converter plus a validator.

More in the Java guides.

Frequently asked questions

How do I map CSV rows to objects?

Annotate the fields with @CsvBindByName and use CsvToBeanBuilder. The class needs a no-argument constructor and setters.

Can I map to a record?

No. OpenCSV instantiates the target reflectively and sets fields, which a record does not allow. Map to a mutable class and convert.

@CsvBindByName or @CsvBindByPosition?

By name, whenever the file has a header, a column inserted upstream then changes nothing. By position only for headerless files, and never both in one class.

How do I parse a date column?

Add @CsvDate("yyyy-MM-dd") alongside the binding annotation. Without it the conversion to a temporal type fails.

Is OpenCSV strict about malformed input?

Not by default. It produces a best-effort parse of broken quoting. Use RFC4180Parser when malformed input should be an error.

How do I skip the header row?

withSkipLines(1) on the reader builder, or reader.skip(1). Bean binding by name consumes it automatically.

Why is every field in my output quoted?

That is CSVWriter’s default. Pass false as the second argument to writeNext, or withApplyQuotesToAll(false) on the bean writer.

Why are my output columns in the wrong order?

@CsvBindByName writes columns alphabetically by default. Supply a mapping strategy with an explicit column order.

How do I read a file larger than memory?

Iterate the CsvToBean rather than calling parse(), or use readNext() in a loop on the raw reader. readAll() and parse() both materialise everything.

What is CSV injection?

A field starting with =, +, - or @ is treated as a formula by spreadsheet software. Prefix such values with a single quote on export, or reject them on input — quoting the field is not enough.