Full API reference, architecture and internals: docs/DOCUMENTATION.md A streaming, zero-dependency CSV parser and writer for Java implementing RFC 4180, built as a single-pass character state machine.
Compiles and runs with nothing but a JDK (11+). Companion project to ttl-cache — where that one is about concurrency and data structures, this one is about parsing and state-machine logic.
Because real CSV breaks it immediately:
name,city,quote
Thabo,"Cape Town, WC","he said ""howzit"""
Lindiwe,"Jozi
(the big one)",fine
That file has a comma inside a field, a quote inside a field, and one record spanning two physical lines. split(",") mangles all three. csv-lite parses them correctly with a four-state character machine (FIELD_START, UNQUOTED_FIELD, QUOTED_FIELD, QUOTE_IN_QUOTED).
- RFC 4180 compliant — quoted fields,
""escaping, embedded commas and newlines, CRLF/LF, missing final newline - Streaming — reads from any
Readerone record at a time, so a 2 GB file needs constant memory Iterable<List<String>>— works directly in the enhanced for-loop- Helpful failures — malformed input raises
CsvFormatExceptionnaming the offending record number - Custom delimiters — e.g.
;for locales where,is the decimal separator - Symmetric writer — minimal quoting, quote-doubling, fluent API; the test suite proves write-then-read is lossless
// Streaming a file (constant memory)
try (Reader r = Files.newBufferedReader(Path.of("transactions.csv"))) {
for (List<String> record : new CsvReader(r)) {
process(record);
}
}
// Small inputs, eagerly
List<List<String>> rows = CsvReader.fromString("a,b\nc,d\n").readAll();
// Writing
StringBuilder sb = new StringBuilder();
new CsvWriter(sb)
.writeRecord(List.of("name", "city"))
.writeRecord(List.of("Thabo", "Cape Town, WC")); // auto-quotedjavac -d out $(find src -name "*.java")
java -cp out dev.csvlite.CsvLiteTestExpected output ends with:
21 passed, 0 failed
Why a character state machine instead of regex? CSV's grammar is context-sensitive in a way that line-oriented regex can't express: whether a comma separates fields depends on whether you're inside quotes, and a record can span multiple physical lines. A four-state machine handles every case in one pass, in O(n), with no backtracking.
Why stream instead of loading the file? Parsing record-by-record from a Reader means memory use is bounded by the largest single record, not the file size — the difference between handling a 2 GB export and an OutOfMemoryError.
Why refuse a second iterator()? The reader wraps a single-use stream. Silently returning an empty second iterator would be a lying API; failing loudly with IllegalStateException makes the contract explicit.
Why does the writer quote minimally? Quoting everything is valid but bloats files and diffs. Quoting only when necessary (delimiter, quote, or newline present) keeps output clean while the round-trip test guarantees it is always parseable.
- No header/column mapping or bean binding — records are
List<String>by design - No charset detection; callers choose the
Readerencoding - Not thread-safe (wraps a single-use stream)
MIT