> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Read and Write CSV Files in Java Without a Library
- URL: https://www.ggorantala.dev/how-to-read-and-write-csv-files-in-java-without-a-library/
- Published: 2026-08-25T05:03:00.000Z
- Updated: 2026-10-31T06:34:12.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-io, java-tutorials

## A rates file that survives commas, quotes and line breaks

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

```log
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:

```log
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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev)) and `Path`/`Files` (see [how to read and write files with NIO.2](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev)).

## Create the folders and compile once

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

```log
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

```

```bash
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:

```java
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");

```

```log
[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`

```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.

```bash
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`

```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.

```bash
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`

```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](https://ggorantala.dev/how-to-use-bigdecimal-financial-calculations-java/?ref=ggorantala.dev).) 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`

```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`

```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:**

```bash
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`

```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:

```bash
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

```

```log
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](https://ggorantala.dev/how-to-read-write-files-nio-java/?ref=ggorantala.dev). Dates come from `LocalDate.parse`, which is covered in [how to work with LocalDate, LocalDateTime and ZonedDateTime](https://ggorantala.dev/how-to-work-localdate-localdatetime-zoneddatetime-java/?ref=ggorantala.dev).

## 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:

```log
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](https://ggorantala.dev/how-to-use-bigdecimal-financial-calculations-java/?ref=ggorantala.dev), [unit tests with JUnit 5](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev), and [parsing JSON with Jackson](https://ggorantala.dev/how-to-parse-json-jackson-java/?ref=ggorantala.dev). 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](https://ggorantala.dev/how-to-filter-transform-list-java-streams/?ref=ggorantala.dev) and [how to collect stream results](https://ggorantala.dev/how-to-collect-stream-results-java/?ref=ggorantala.dev).
- **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

- JDK 21 API docs: [java.io.Reader](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/io/Reader.html?ref=ggorantala.dev), [java.nio.file.Files](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/Files.html?ref=ggorantala.dev), [java.math.BigDecimal](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/math/BigDecimal.html?ref=ggorantala.dev)
- [RFC 4180: Common Format and MIME Type for CSV Files](https://www.rfc-editor.org/rfc/rfc4180?ref=ggorantala.dev)
- [OWASP: CSV Injection](https://owasp.org/www-community/attacks/CSV%5FInjection?ref=ggorantala.dev)