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 whyc = 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 ofab"cis treated as plain text. \r\n,\nand a lone\rall end a row. Files from Windows, Linux and old Mac tools all show up eventually.- The
rowHasContentflag 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 calleddate, 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 intoCsvWriterbecause 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\rand a BOM. - Wrote a writer that quotes only when needed and doubles inner quotes.
- Mapped rows to a
Raterecord by header name, withBigDecimalfor 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.