Skip to content

How to Read and Write CSV Files in Java Without a Library

Build a small CSV reader and writer in plain Java 21 that handles quoted commas, line breaks and BOMs, then use it on a currency rates file. No dependencies.

how-to-tutorials java-io java-tutorials
Gopi Gorantala
Reading Progress

On This Page

A rates file that survives commas, quotes and line breaks

This is the file the finished code writes, with the line endings made visible:

date,base,quote,rate,note\r\n
2026-10-23,EUR,USD,1.0842,ECB fixing\r\n
2026-10-23,GBP,USD,1.2711,"Quoted ""mid"", not close"\r\n
2026-10-24,USD,JPY,151.20,"Line one\n
line two"\r\n

And this is what it reads back, byte for byte equal to what went in:

Rate[date=2026-10-23, base=EUR, quote=USD, rate=1.0842, note=ECB fixing]
Rate[date=2026-10-23, base=GBP, quote=USD, rate=1.2711, note=Quoted "mid", not close]
Rate[date=2026-10-24, base=USD, quote=JPY, rate=151.20, note=Line one
line two]
round trip equal: true

Look at the last row. That note field contains a real line break, and the second row's note has a comma and two quotes. Those are the three things that break the usual advice, which is line.split(","). This tutorial shows how to read and write CSV files in Java without a library by building a parser of about 100 lines that gets those cases right, then wiring it to a Rate record for a small currency rates feed. You'll end with a reader that streams a million rows in a few seconds, a writer that quotes only when it has to, and 25 checks that prove both.

It's for a Java developer who needs to load or export a CSV today and either can't add a dependency or doesn't want one for a 40-line job. If you can add Jackson CSV, Commons CSV or OpenCSV, do. I'd still build this once, because afterwards you'll know what those libraries are doing for you.

What you need

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
IntelliJ IDEA or Eclipse optional, any current release Help → About
Build tool none; plain javac

That's all. Java 16 or newer is enough for the record; nothing else here needs Java 21. No Maven, no Gradle, no container. The code uses java.io, java.nio.file, java.time and java.math, all in the JDK.

I built and ran everything below on the command line. I couldn't click through IntelliJ IDEA or Eclipse, so those two sections come from their documentation and are marked as such. I also couldn't download JUnit in my environment, so the tests are a plain main method with a tiny check helper. Moving them to JUnit 5 later is mechanical: each check(...) line becomes one @Test with an assertThat.

You should already be comfortable with records (see how to use Java records) and Path/Files (see how to read and write files with NIO.2).

Create the folders and compile once

The primary path here is a terminal. Make this layout:

csv-rates/
├── src/main/java/dev/ggorantala/rates/
│   ├── CsvParser.java
│   ├── CsvWriter.java
│   ├── Rate.java
│   ├── RatesCsv.java
│   └── App.java
└── src/test/java/dev/ggorantala/rates/
    └── Checks.java
mkdir -p csv-rates/src/main/java/dev/ggorantala/rates csv-rates/src/test/java/dev/ggorantala/rates
cd csv-rates

In IntelliJ IDEA (from the docs, not executed)

Choose File → New → Project, pick Java with the IntelliJ build system, and select a JDK 21. Mark src/main/java as Sources Root and src/test/java as Test Sources Root (right-click the folder → Mark Directory as). Create the package dev.ggorantala.rates under each and paste the files from the steps below.

In Eclipse (from the docs, not executed)

File → New → Java Project, set the JRE to 21, and on the Source tab add src/main/java and src/test/java as source folders. Then File → New → Package for dev.ggorantala.rates in each.

Either IDE can run App and Checks with a right-click → Run As / Run. Nothing here needs annotation processing or plugins.

How to read and write CSV files in Java, step by step

I'll go in the order the data flows: first a parser that knows about quotes, then a writer that produces what the parser accepts, then the glue to a Rate record. The part that cost me time wasn't the quoting, it was deciding what a blank line means (Step 1 explains).

Step 1: Write a parser that understands quotes

Goal: turn a stream of characters into rows of fields, treating a quoted field as one unit, whatever it contains.

Why not split(",")? Because the delimiter inside quotes isn't a delimiter. Here's the naive version on the GBP row, so you can see the failure rather than take my word for it:

String line = "2026-10-23,GBP,USD,1.2711,\"Quoted \"\"mid\"\", not close\"";
System.out.println(Arrays.toString(line.split(",")));
System.out.println(line.split(",").length + " fields, expected 5");
[2026-10-23, GBP, USD, 1.2711, "Quoted ""mid"",  not close"]
6 fields, expected 5

Six fields, and the quotes are still glued to the text. A real parser needs state: am I inside quotes right now?

src/main/java/dev/ggorantala/rates/CsvParser.java

package dev.ggorantala.rates;

import java.io.IOException;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

/** Minimal RFC 4180 reader: quotes, "" escapes, commas and line breaks inside quotes. */
public final class CsvParser {

    private CsvParser() {}

    public static void parse(Reader in, char delimiter, Consumer<List<String>> rowHandler) {
        try {
            doParse(in, delimiter, rowHandler);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static void doParse(Reader in, char delimiter, Consumer<List<String>> rowHandler)
            throws IOException {
        List<String> row = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        boolean inQuotes = false;
        boolean fieldWasQuoted = false;
        boolean rowHasContent = false;
        int line = 1;
        int c = in.read();
        if (c == '\uFEFF') {            // skip a UTF-8 byte order mark if present
            c = in.read();
        }
        while (c != -1) {
            char ch = (char) c;
            if (inQuotes) {
                if (ch == '"') {
                    int next = in.read();
                    if (next == '"') {          // "" inside quotes is one literal quote
                        field.append('"');
                        c = in.read();
                        continue;
                    }
                    inQuotes = false;            // closing quote; 'next' is handled below
                    c = next;
                    continue;
                }
                if (ch == '\n') line++;
                field.append(ch);
                c = in.read();
                continue;
            }
            if (ch == '"' && field.length() == 0 && !fieldWasQuoted) {
                inQuotes = true;
                fieldWasQuoted = true;
                rowHasContent = true;
            } else if (ch == delimiter) {
                row.add(field.toString());
                field.setLength(0);
                fieldWasQuoted = false;
                rowHasContent = true;
            } else if (ch == '\r' || ch == '\n') {
                if (ch == '\r') {
                    int next = in.read();
                    if (next != '\n') {         // lone CR also ends a row
                        emit(row, field, rowHasContent, rowHandler);
                        row = new ArrayList<>();
                        fieldWasQuoted = false;
                        rowHasContent = false;
                        line++;
                        c = next;
                        continue;
                    }
                }
                emit(row, field, rowHasContent, rowHandler);
                row = new ArrayList<>();
                fieldWasQuoted = false;
                rowHasContent = false;
                line++;
            } else {
                if (fieldWasQuoted) {
                    throw new IllegalArgumentException(
                            "Line " + line + ": text after closing quote: '" + ch + "'");
                }
                field.append(ch);
                rowHasContent = true;
            }
            c = in.read();
        }
        if (inQuotes) {
            throw new IllegalArgumentException("Line " + line + ": quoted field never closed");
        }
        emit(row, field, rowHasContent, rowHandler);
    }

    private static void emit(List<String> row, StringBuilder field, boolean hasContent,
                             Consumer<List<String>> handler) {
        if (!hasContent) {
            return;                              // blank line: skip
        }
        row.add(field.toString());
        field.setLength(0);
        handler.accept(List.copyOf(row));
    }
}

The parser reads one character at a time from a Reader and hands each finished row to a callback. It never holds more than one row, which is what makes streaming possible later.

A few lines deserve a second look:

  • Inside quotes, a " followed by another " is one literal quote. Any other character after a " means the quote closed, and that character is handled by the normal branch, which is why c = next; continue; hands it back instead of consuming it.
  • A " only opens a quoted field at the start of a field (field.length() == 0 && !fieldWasQuoted). A quote in the middle of ab"c is treated as plain text.
  • \r\n, \n and a lone \r all end a row. Files from Windows, Linux and old Mac tools all show up eventually.
  • The rowHasContent flag is the blank-line decision. A line with nothing on it is skipped. The cost: a row whose only field is empty can't be told apart from a blank line, so it vanishes. That's a property of the format, not a bug, and I'd rather skip trailing blank lines (which real exports have) than emit fake rows.
  • The first character is checked for , the byte order mark that Excel puts at the start of "CSV UTF-8" files. Without this, your first column is called date, and nothing matches it.

Checkpoint: compile what you have.

mkdir -p out
javac -d out src/main/java/dev/ggorantala/rates/CsvParser.java

No output means it compiled.

Step 2: Write a writer that quotes only when it must

Goal: produce files that the parser (and Excel, and Python's csv module) read back unchanged.

src/main/java/dev/ggorantala/rates/CsvWriter.java

package dev.ggorantala.rates;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.util.List;

/** Writes rows the way CsvParser reads them. Uses \r\n, as RFC 4180 says. */
public final class CsvWriter implements AutoCloseable {

    private final Writer out;
    private final char delimiter;

    public CsvWriter(Writer out, char delimiter) {
        this.out = out;
        this.delimiter = delimiter;
    }

    public void writeRow(List<String> fields) {
        try {
            for (int i = 0; i < fields.size(); i++) {
                if (i > 0) out.write(delimiter);
                out.write(escape(fields.get(i)));
            }
            out.write("\r\n");
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    String escape(String value) {
        if (value == null) {
            return "";
        }
        boolean needsQuotes = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0
                || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0
                || value.startsWith(" ") || value.endsWith(" ");
        if (!needsQuotes) {
            return value;
        }
        return '"' + value.replace("\"", "\"\"") + '"';
    }

    @Override
    public void close() {
        try {
            out.close();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

The rule is the mirror of the parser: a field needs quotes if it contains the delimiter, a quote, \n or \r, and every embedded " is doubled. I also quote values with a leading or trailing space, because some readers trim unquoted fields and you'd lose them. null becomes an empty field. Rows end in \r\n, which is what RFC 4180 asks for.

The class implements AutoCloseable so try-with-resources closes the underlying writer, and it wraps IOException in UncheckedIOException so you can call it from a lambda.

Checkpoint: compile both.

javac -d out src/main/java/dev/ggorantala/rates/CsvParser.java src/main/java/dev/ggorantala/rates/CsvWriter.java

Step 3: Give each row a type

Goal: stop passing List<String> around and get a Rate with a LocalDate and a BigDecimal.

src/main/java/dev/ggorantala/rates/Rate.java

package dev.ggorantala.rates;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;

public record Rate(LocalDate date, String base, String quote, BigDecimal rate, String note) {

    public static final List<String> HEADER = List.of("date", "base", "quote", "rate", "note");

    /** Builds a Rate from one row, finding columns by header name so column order doesn't matter. */
    static Rate fromRow(Map<String, Integer> columns, List<String> row, int rowNumber) {
        try {
            return new Rate(
                    LocalDate.parse(get(columns, row, "date")),
                    get(columns, row, "base"),
                    get(columns, row, "quote"),
                    new BigDecimal(get(columns, row, "rate")),
                    columns.containsKey("note") ? get(columns, row, "note") : "");
        } catch (RuntimeException e) {
            throw new IllegalArgumentException("Row " + rowNumber + " " + row + ": " + e.getMessage(), e);
        }
    }

    private static String get(Map<String, Integer> columns, List<String> row, String name) {
        Integer index = columns.get(name);
        if (index == null) throw new IllegalArgumentException("missing column '" + name + "'");
        if (index >= row.size()) throw new IllegalArgumentException("row has only " + row.size() + " fields");
        return row.get(index);
    }

    List<String> toRow() {
        return List.of(date.toString(), base, quote, rate.toPlainString(), note);
    }
}

Two choices to flag. The rate is a BigDecimal, never a double: 151.20 should come back as 151.20, not 151.2, and money-like values shouldn't carry binary rounding. (More on that in the upcoming BigDecimal tutorial, how to use BigDecimal for financial calculations.) And columns are found by header name through a Map<String, Integer>, so a file with columns in a different order still loads. Any problem is rethrown with the row number and the row's contents, which saves you ten minutes of guessing when row 40,312 has a typo.

Step 4: Connect it to files

Goal: readAll, readEach and write on a Path.

src/main/java/dev/ggorantala/rates/RatesCsv.java

package dev.ggorantala.rates;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;

public final class RatesCsv {

    private RatesCsv() {}

    /** Reads the whole file into memory. Fine for thousands of rows. */
    public static List<Rate> readAll(Path file) {
        List<Rate> rates = new ArrayList<>();
        readEach(file, rates::add);
        return rates;
    }

    /** Streams rows one at a time, so memory use doesn't grow with file size. */
    public static void readEach(Path file, Consumer<Rate> consumer) {
        try (Reader in = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            Map<String, Integer> columns = new HashMap<>();
            int[] rowNumber = {0};
            CsvParser.parse(in, ',', row -> {
                if (rowNumber[0]++ == 0) {
                    for (int i = 0; i < row.size(); i++) {
                        columns.put(row.get(i).strip().toLowerCase(), i);
                    }
                    return;
                }
                consumer.accept(Rate.fromRow(columns, row, rowNumber[0]));
            });
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public static void write(Path file, List<Rate> rates) {
        try (Writer w = Files.newBufferedWriter(file, StandardCharsets.UTF_8);
             CsvWriter csv = new CsvWriter(w, ',')) {
            csv.writeRow(Rate.HEADER);
            for (Rate r : rates) {
                csv.writeRow(r.toRow());
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

Both methods name the charset: StandardCharsets.UTF_8. Files.newBufferedReader(path) defaults to UTF-8 too, but writing it out removes the question.

readAll is the convenient one. readEach is the one to use when the file might be big, because rows go to your Consumer and are then garbage. Step 6 puts a number on that.

Step 5: A main method to see it work

src/main/java/dev/ggorantala/rates/App.java

package dev.ggorantala.rates;

import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;

public class App {
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("rates", ".csv");
        List<Rate> rates = List.of(
                new Rate(LocalDate.of(2026, 10, 23), "EUR", "USD", new BigDecimal("1.0842"), "ECB fixing"),
                new Rate(LocalDate.of(2026, 10, 23), "GBP", "USD", new BigDecimal("1.2711"), "Quoted \"mid\", not close"),
                new Rate(LocalDate.of(2026, 10, 24), "USD", "JPY", new BigDecimal("151.20"), "Line one\nline two"));
        RatesCsv.write(file, rates);
        System.out.println("--- file as bytes on disk ---");
        System.out.println(Files.readString(file).replace("\r", "\\r").replace("\n", "\\n\n"));
        System.out.println("--- read back ---");
        RatesCsv.readAll(file).forEach(System.out::println);
        System.out.println("round trip equal: " + rates.equals(RatesCsv.readAll(file)));
    }
}

Checkpoint:

javac -d out src/main/java/dev/ggorantala/rates/*.java
java -cp out dev.ggorantala.rates.App

You should see exactly the output at the top of this article. If the round trip equal line says true, the hard part's done.

Check it works

Open the file the app wrote (it's in your temp directory; print file in App if you want the path) in a text editor, then in a spreadsheet. A spreadsheet should show three data rows with the last note spread across two lines inside one cell. That's the check no unit test replaces: a different program agrees with your file.

If Excel garbles accented characters, that's usually the BOM question. I've seen Excel need a leading  to treat a file as UTF-8, and I didn't test that on a machine with Excel, so treat it as a lead and not a fact. Your own reader already handles a BOM when one is present.

Write the tests

src/test/java/dev/ggorantala/rates/Checks.java

package dev.ggorantala.rates;

import java.io.StringReader;
import java.io.StringWriter;
import java.math.BigDecimal;
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.List;
import java.util.Random;

public class Checks {
    static int passed, failed;

    static void check(String name, boolean ok) {
        if (ok) passed++; else failed++;
        System.out.println((ok ? "PASS  " : "FAIL  ") + name);
    }

    static List<List<String>> parse(String text, char d) {
        List<List<String>> rows = new ArrayList<>();
        CsvParser.parse(new StringReader(text), d, rows::add);
        return rows;
    }

    static boolean throwsIAE(Runnable r, String contains) {
        try { r.run(); return false; }
        catch (IllegalArgumentException e) { System.out.println("      -> " + e.getMessage()); return e.getMessage().contains(contains); }
    }

    public static void main(String[] args) throws Exception {
        check("plain row", parse("a,b,c\n", ',').equals(List.of(List.of("a", "b", "c"))));
        check("no trailing newline", parse("a,b", ',').equals(List.of(List.of("a", "b"))));
        check("CRLF line endings", parse("a,b\r\nc,d\r\n", ',').size() == 2);
        check("empty fields kept", parse("a,,c\n,,\n", ',').equals(List.of(List.of("a", "", "c"), List.of("", "", ""))));
        check("comma in quotes", parse("\"a,b\",c\n", ',').get(0).equals(List.of("a,b", "c")));
        check("escaped quote", parse("\"say \"\"hi\"\"\",x\n", ',').get(0).get(0).equals("say \"hi\""));
        check("newline in quotes", parse("\"l1\nl2\",x\n", ',').get(0).get(0).equals("l1\nl2"));
        check("blank lines skipped", parse("a\n\n\nb\n", ',').size() == 2);
        check("empty quoted field", parse("\"\",x\n", ',').get(0).equals(List.of("", "x")));
        check("BOM skipped", parse("date,base\n", ',').get(0).get(0).equals("date"));
        check("semicolon delimiter", parse("a;b,c;d\n", ';').get(0).equals(List.of("a", "b,c", "d")));
        check("unclosed quote reported", throwsIAE(() -> parse("a,\"oops\nb\n", ','), "never closed"));
        check("junk after quote reported", throwsIAE(() -> parse("\"a\"x,b\n", ','), "after closing quote"));

        CsvWriter w = new CsvWriter(new StringWriter(), ',');
        check("escape plain", w.escape("abc").equals("abc"));
        check("escape comma", w.escape("a,b").equals("\"a,b\""));
        check("escape quote", w.escape("a\"b").equals("\"a\"\"b\""));
        check("escape null is empty", w.escape(null).equals(""));

        // round trip on random nasty strings
        Random rnd = new Random(42);
        String alphabet = "ab ,\"\n\r;\t";
        List<List<String>> rows = new ArrayList<>();
        for (int i = 0; i < 5000; i++) {
            List<String> row = new ArrayList<>();
            int n = 1 + rnd.nextInt(5);
            for (int j = 0; j < n; j++) {
                StringBuilder sb = new StringBuilder();
                int len = rnd.nextInt(8);
                for (int k = 0; k < len; k++) sb.append(alphabet.charAt(rnd.nextInt(alphabet.length())));
                row.add(sb.toString());
            }
            // a row whose only field is empty is a blank line; CSV cannot tell it apart
            if (row.size() == 1 && row.get(0).isEmpty()) row.set(0, "x");
            rows.add(row);
        }
        StringWriter sw = new StringWriter();
        try (CsvWriter cw = new CsvWriter(sw, ',')) { rows.forEach(cw::writeRow); }
        check("round trip 5000 random rows", parse(sw.toString(), ',').equals(rows));

        // Rate file round trip
        Path f = Files.createTempFile("rates", ".csv");
        List<Rate> rates = List.of(
                new Rate(LocalDate.of(2026, 10, 23), "EUR", "USD", new BigDecimal("1.0842"), "a,b"),
                new Rate(LocalDate.of(2026, 10, 24), "USD", "JPY", new BigDecimal("151.20"), "x\ny"));
        RatesCsv.write(f, rates);
        check("file round trip", RatesCsv.readAll(f).equals(rates));
        check("BigDecimal scale kept (151.20)", RatesCsv.readAll(f).get(1).rate().toPlainString().equals("151.20"));

        Files.writeString(f, "quote,rate,base,date\nUSD,1.5,EUR,2026-01-02\n", StandardCharsets.UTF_8);
        check("columns found by header name", RatesCsv.readAll(f).get(0).equals(
                new Rate(LocalDate.of(2026, 1, 2), "EUR", "USD", new BigDecimal("1.5"), "")));

        Files.writeString(f, "date,base,quote,rate\n2026-01-02,EUR,USD,abc\n");
        check("bad number names the row", throwsIAE(() -> RatesCsv.readAll(f), "Row 2"));
        Files.writeString(f, "date,base,quote\n2026-01-02,EUR,USD\n");
        check("missing column named", throwsIAE(() -> RatesCsv.readAll(f), "missing column 'rate'"));
        Files.writeString(f, "date,base,quote,rate\n2026-01-02,EUR\n");
        check("short row named", throwsIAE(() -> RatesCsv.readAll(f), "only 2 fields"));

        // streaming a big file
        Path big = Files.createTempFile("big", ".csv");
        try (var bw = Files.newBufferedWriter(big); CsvWriter cw = new CsvWriter(bw, ',')) {
            cw.writeRow(Rate.HEADER);
            for (int i = 0; i < 1_000_000; i++) {
                cw.writeRow(List.of("2026-01-02", "EUR", "USD", "1.0842", "row " + i + ", ok"));
            }
        }
        long[] count = {0};
        long before = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
        long t0 = System.nanoTime();
        RatesCsv.readEach(big, r -> count[0]++);
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("      -> streamed " + count[0] + " rows from " + Files.size(big) / 1_000_000 + " MB in " + ms + " ms");
        check("streamed 1,000,000 rows", count[0] == 1_000_000);

        System.out.println("\n" + passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

Run them:

javac -d out src/main/java/dev/ggorantala/rates/*.java src/test/java/dev/ggorantala/rates/Checks.java
java -Xmx64m -cp out dev.ggorantala.rates.Checks
PASS  plain row
PASS  no trailing newline
PASS  CRLF line endings
PASS  empty fields kept
PASS  comma in quotes
PASS  escaped quote
PASS  newline in quotes
PASS  blank lines skipped
PASS  empty quoted field
PASS  BOM skipped
PASS  semicolon delimiter
      -> Line 3: quoted field never closed
PASS  unclosed quote reported
      -> Line 1: text after closing quote: 'x'
PASS  junk after quote reported
PASS  escape plain
PASS  escape comma
PASS  escape quote
PASS  escape null is empty
PASS  round trip 5000 random rows
PASS  file round trip
PASS  BigDecimal scale kept (151.20)
PASS  columns found by header name
      -> Row 2 [2026-01-02, EUR, USD, abc]: Character a is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
PASS  bad number names the row
      -> Row 2 [2026-01-02, EUR, USD]: missing column 'rate'
PASS  missing column named
      -> Row 2 [2026-01-02, EUR]: row has only 2 fields
PASS  short row named
      -> streamed 1000000 rows from 43 MB in 2311 ms
PASS  streamed 1,000,000 rows

25 passed, 0 failed

Two of these deserve a comment. The random round trip generates 5,000 rows from an alphabet made mostly of troublemakers (,, ", newline, carriage return, ;, tab, space) and demands that write-then-parse returns the identical rows. It's the test that found nothing, which is the best outcome, but it's also the one I trust most.

And the last check runs with -Xmx64m, a 64 MB heap, over a 43 MB file with a million rows. It passes because readEach never holds more than one row. The 2.3 seconds is on a 2-core cloud box with a cold JVM, so don't read it as a benchmark.

What the parser does with each character

The parser is a two-state machine: outside quotes and inside quotes. Outside, , ends a field and a line break ends a row. Inside, nothing is special except ", and a " is only special because of what comes after it. "" means a literal quote and stays inside; anything else means the field is over.

That single lookahead (in.read() after a quote) is the entire trick. Everything else is bookkeeping: remembering whether the current field started with a quote so you can reject "a"x, and remembering whether the row has any content so blank lines don't become rows with one empty field.

The writer is the inverse, and the pair obeys one law: parse(write(rows)) == rows, except for the single-empty-field row from Step 1. The random test checks that law directly, which is cheaper than writing a hundred examples.

For reading files in general (memory, Files.lines, buffers), the sibling article on reading large files line by line covers the trade-offs; for the NIO.2 Path and Files API used here, see how to read and write files with NIO.2. Dates come from LocalDate.parse, which is covered in how to work with LocalDate, LocalDateTime and ZonedDateTime.

Troubleshooting

These are the problems I hit or provoked while building this.

Too many fields from split(","). Output: 6 fields, expected 5. Cause: the delimiter inside a quoted field. Fix: use the parser, not String.split.

Files.lines or readAllLines returns too many lines. I ran Files.lines(f).count() on a file with one header and one record whose note held a line break:

Files.lines count: 3 (file has 1 header + 1 record)

Cause: those methods split on every line break, quoted or not. A quoted field can span lines, so line-based reading is wrong for CSV in general. Fix: read characters through a Reader, as CsvParser does.

java.nio.charset.MalformedInputException: Input length = 1. I wrote a file with ISO_8859_1, then read it with the UTF-8 default. Cause: the file isn't UTF-8. Fix: ask where the file came from and pass the matching charset to newBufferedReader. The reverse mistake, reading UTF-8 bytes as ISO-8859-1, doesn't throw at all; it silently gives you é where you wanted é.

Line 3: quoted field never closed. A quote opened and the file ended. The line number is where the file ended, not where the quote opened, because the parser only discovers the problem at the end. Look for a stray " above that line.

Line 1: text after closing quote: 'x'. The file has "a"x. Some generators do this when they forget to escape. Fix the generator, or relax the parser if you must accept it.

Row 2 [...]: missing column 'rate'. Header names must match after lower-casing and trimming. A BOM on the first column name (when the parser isn't skipping it) is a classic cause; so is Rate with a trailing space in a hand-edited header, which strip() already covers.

Character a is neither a decimal digit number.... A non-numeric rate. The wrapper adds the row number and contents. That message comes from BigDecimal, and it's the JDK's own wording.

Taking it to production

  • Don't trust the input. Every field is a string from outside. If you ever export user-supplied text to a file that someone opens in a spreadsheet, a value starting with =, +, - or @ can be interpreted as a formula. OWASP calls this CSV injection. The usual mitigation is to prefix such values with a single quote or tab on export. I haven't built that into CsvWriter because it changes the data you wrote.
  • Delimiters. Some locales export with ;. The parser and writer take the delimiter as an argument, and a check covers it.
  • When to switch to a library. If you need type conversion by annotation, a schema, or you're reading files from partners with surprising dialects, use a library (Jackson CSV, Commons CSV, OpenCSV). This code reads RFC 4180 and little else.
  • Large files. Use readEach, and keep the consumer cheap or hand work to a bounded queue.
  • Next tutorials: BigDecimal for money, unit tests with JUnit 5, and parsing JSON with Jackson. The first and second aren't published yet. Reading the output of this parser with streams is in how to filter and transform a list with Java streams and how to collect stream results.
  • Version notes: the code needs Java 16+ for record. Nothing in it changed between Java 17, 21 and later releases.

Recap

  • Made a state-machine parser that reads quotes, "", embedded commas and line breaks, \r\n, lone \r and a BOM.
  • Wrote a writer that quotes only when needed and doubles inner quotes.
  • Mapped rows to a Rate record by header name, with BigDecimal for the rate.
  • Streamed a million rows through a 64 MB heap.
  • Rules to keep: never split(","), never read CSV line by line, always name the charset, and report errors with a row number.

CSV in Java: questions people ask

How do I read a CSV file in Java without a library? Open a Reader with Files.newBufferedReader(path, UTF_8) and feed it to a character-level parser like CsvParser above. BufferedReader.readLine() plus split(",") only works for files with no quoted fields.

How do I handle commas inside a CSV field? Wrap the field in double quotes and double any quotes inside it. When reading, treat everything between quotes as one field. Both directions are in the code above.

Can a CSV field contain a line break? Yes, when it's quoted. That's why Files.lines and readAllLines can't be trusted for CSV.

Is it better to use OpenCSV or Apache Commons CSV? If you can take the dependency, yes, use a maintained library for anything beyond a simple file. Check the current release on Maven Central; I couldn't reach it from my environment this run, so I haven't pinned a version here.

References

how-to-tutorialsjava-iojava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments