kotlin-csv: one CSV parser for JVM, JS, Wasm and Native targets
Pure Kotlin CSV Reader/Writer
At a glance
- What is it?
- kotlin-csv is a pure Kotlin Multiplatform CSV reader and writer whose API is expressed in kotlinx.io types, with a dialect object for format rules and explicit policies for ragged rows and empty fields. It fits codebases that must parse the same CSV on a server, in a browser and on a phone, and it leaves charset handling to the JVM side.
- Who is it for?
- kotlin-csv suits Kotlin Multiplatform codebases that need one CSV implementation across JVM, JS, Wasm and Native, and teams willing to pin behaviour with an explicit CsvDialect and row-length policies. It does not suit JVM-only services, where a library already on the classpath is cheaper, nor work that needs a documented charset story on non-JVM targets.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 135 days ago.
- What is it written in?
- Mainly Kotlin, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
kotlin-csv keeps a single CSV implementation on JVM, JS, Wasm and Native
kotlin-csv is a pure Kotlin Multiplatform CSV reader and writer, published under the group com.jsoizo. The problem it addresses is not CSV itself but the portability of CSV handling. A parser built on java.io.Reader works on a server and dies the moment the same module has to run in a browser or on a phone. The multiplatform artifact here resolves the JVM, JS, Kotlin/Wasm and supported Kotlin/Native variants from a Kotlin Multiplatform project, so one dependency declaration covers the whole matrix.
The published native targets are macosArm64, iosArm64, iosSimulatorArm64, linuxX64, linuxArm64 and mingwX64, and the published Wasm targets are wasmJs and wasmWasi. A single-platform Gradle project can also depend on a platform artifact directly, for example kotlin-csv-jvm, kotlin-csv-js, kotlin-csv-wasm-js, kotlin-csv-macosarm64 or kotlin-csv-linuxx64. The practical audience is a team whose Kotlin code genuinely has to run in more than one of those places. If your service is JVM-only, this library buys you a set of Kotlin-native APIs and nothing else, and that is a thin reason to add a dependency.
csvReader and csvWriter hand back stateless instances, not global helpers
The entry points are two builder functions, csvReader() and csvWriter(). They are described as returning reusable, stateless instances, with the guidance to create them once and share them across calls. That is the shape of the library in one line: no static entry point, no mutable global state, and configuration attached to an object you own.
The configuration is split in two. A CsvDialect value object holds the four characters that define the CSV format itself, namely delimiter, quoteChar, escapeChar and lineTerminator. Format-independent policies stay on the reader or writer Config, so the reader owns rules about empty lines and row length while the writer owns rules about quoting and the trailing terminator. A dialect can be reused between the two sides, which matters when a file is written by one service and read by another.
Two dialects are named in the documentation, CsvDialect.RFC4180 as the default and CsvDialect.TSV for tab separated values, and a custom one is built by passing the characters you want.
Reading: eager strings, cold sequences and header maps that keep column order
Getting the module onto the classpath is a single coordinate in either Gradle dialect. The Kotlin DSL form is:
implementation("com.jsoizo:kotlin-csv:2.0.0")The Groovy DSL equivalent is:
implementation 'com.jsoizo:kotlin-csv:2.0.0'Maven users add the platform artifact they need, such as kotlin-csv-jvm or kotlin-csv-js, each with its own dependency block and version element. Once the dependency is in place, the imports you need come from com.jsoizo.kotlincsv and its reader and writer subpackages:
import com.jsoizo.kotlincsv.csvReader
import com.jsoizo.kotlincsv.csvWriter
import com.jsoizo.kotlincsv.reader.CsvNullFieldIndicator
import com.jsoizo.kotlincsv.reader.readFromFile
import com.jsoizo.kotlincsv.reader.withHeader
import com.jsoizo.kotlincsv.writer.WriteQuoteMode
import com.jsoizo.kotlincsv.writer.writeToFile
val reader = csvReader()
val writer = csvWriter()Reading comes in two flavours. Reading a String is eager and returns a List<List<String>> built up front, while reading a file hands you a cold Sequence inside a block that owns the open resource and closes it on exit:
val rows: List<List<String>> = reader.readAll("a,b,c\nd,e,f")
reader.readFromFile(File("data.csv")) { rows ->
rows.forEach { println(it) }
}
// With a header row (returns LinkedHashMap to preserve column order)
reader.readFromFile(File("data.csv")) { rows ->
val records = rows.withHeader().toList()
println(records.first()["id"])
}That cold sequence is the part worth planning around. Because rows are parsed on demand, take(n) and similar operations stop early without reading the rest of the file, so a peek at the first hundred rows of a multi-gigabyte export costs what the peek needs. The header variant returns a LinkedHashMap per row specifically so column order survives the trip into a map.
Ragged rows stop the parse by default, and the row counter is not a line number
The behaviour that will surprise people coming from lenient parsers is the row length policy. Two options decide what happens when a row does not match the shape of the first row. excessFieldsRowBehaviour governs rows with more fields than the first row and accepts ERROR, IGNORE or TRIM. insufficientFieldsRowBehaviour governs rows with fewer fields and accepts ERROR, IGNORE or EMPTY_STRING. Both default to ERROR, which means a single stray comma in a large export throws rather than producing a quietly wrong table.
That default is a defensible choice for data that feeds decisions, and an expensive one for data that comes from a partner who does not guarantee column counts. The knob to change is one property on the reader Config, set at construction time, so switching a file to tolerant mode is a different reader instance rather than a per-row decision.
skipEmptyLine sits in the same family and defaults to false. It drops rows that are entirely empty, and the order of operations matters: the empty row is removed before the field count check, so a blank line does not trip the excess or insufficient field rules. The exception raised on a length mismatch is CsvFieldNumDifferentException, and its rowNum property counts CSV rows after reader filters such as skipEmptyLine have been applied. It is not a physical source line number, so do not use it to point a user at a specific line of a file that has blank lines or comments interleaved.
Empty string and null are different values, and the writer can quote everything
A CSV file cannot store a null, so any library has to invent a convention for it. kotlin-csv makes that convention explicit. The reader option nullFieldIndicator decides which empty fields the nullable reader APIs expose as null, with the values NEITHER as the default, EMPTY_SEPARATORS, EMPTY_QUOTES and BOTH. The two middle options correspond to the two ways a field can be empty in the file, an unquoted empty field or a quoted empty field.
// Nullable reads can distinguish "" from an unquoted empty field.
val nullableRows = csvReader {
nullFieldIndicator = CsvNullFieldIndicator.EMPTY_SEPARATORS
}.readAllNullable("\"empty\",\"null\"\n\"\",")The nullable reader APIs, readNullable, readAllNullable and the nullable I/O overloads, return String? values. One asymmetry is documented and worth internalising: withNullableHeader() keeps header keys as non-null String, so a header cell that looks null is treated as the empty header name, while data values in the same row remain nullable. Header names are therefore never null even when the data is.
The writer side has its own choices. quoteMode defaults to CANONICAL, which wraps a field in the quote character only when the format requires it, and the alternatives are ALL, which quotes every field, and NON_NUMERIC. outputLastLineTerminator defaults to true, so a trailing terminator is emitted after the final row.
val csv: String = writer.writeAll(rows)
// To a File
writer.writeToFile(rows, File("out.csv"))
// Nullable writes emit null as an unquoted empty field.
val nullableCsv = csvWriter {
quoteMode = WriteQuoteMode.ALL
}.writeAllNullable(listOf(listOf(null, "", "value")))Note the asymmetry at the end of that snippet. The writer emits null as an unquoted empty field, and the reader only turns an unquoted empty field into null when nullFieldIndicator is EMPTY_SEPARATORS or BOTH. A round trip therefore preserves nulls only if both sides are configured to agree, which is a detail most callers only discover when a column of nulls turns into a column of empty strings.
Dialect, tab separated values and a custom delimiter in one builder
Format rules are kept in one place, and both TSV and a bespoke dialect are one property assignment away:
val tsvReader = csvReader {
dialect = CsvDialect.TSV
skipEmptyLine = true
}
val customWriter = csvWriter {
dialect = CsvDialect(delimiter = ';', escapeChar = '\\')
quoteMode = WriteQuoteMode.ALL
}A dialect built by hand takes the delimiter, quote and escape characters and the line terminator. Since a CsvDialect value is just data, a team can define one dialect object, share it between the writer that produces files and the reader that consumes them, and unit test the pair without touching a filesystem. That is a cleaner arrangement than hardcoding a comma in several call sites.
The same separation applies to file locations. The file helpers accept String paths, kotlinx.io.files.Path and, on the JVM, java.io.File. In-memory streams have their own overloads, read(source) taking a commonMain kotlinx.io.Source and read(stream) taking a JVM java.io.InputStream, and write(rows, sink) and write(rows, stream) on the writer side. The row source can be a Sequence or a List, so a lazily mapped sequence can be written without first collecting it in memory.
Charset is a JVM-only argument, and the 2.0 boundary comes with a migration guide
One capability is explicitly limited to the JVM. Charset is passed as an argument on the I/O call rather than configured on the reader, and the documentation states this is JVM-only. A multiplatform project that has to read a legacy encoding therefore needs a different plan for each target, and the README does not describe a portable path for that. The same JVM-only pattern applies to java.io.File and java.io.InputStream, which are listed as JVM-only alternatives to the kotlinx.io types rather than as the primary interface.
On versions, the repository shows a 2.0.0 release and a 1.11.0 release both dated 2026-05-24, with 1.10.0 back on 2024-08-11. The root of the repository holds a file named V2_MIGRATION_GUIDE.md, and the README does not summarize what the 2.0 API changed, so a project pinned to a 1.x version should read that guide before bumping rather than assuming the upgrade is source compatible. A benchmark directory also exists at the root, but the documentation publishes no numbers, so there is no throughput figure to plan capacity against.
Snapshot builds are published to Sonatype Central Portal Snapshots from the active development branch, and they are described as unstable. The documentation is explicit that they may change at any time, and the version to use is whatever the active development branch build file currently declares, for example 2.0.0-SNAPSHOT. The build needs the snapshot repository declared with snapshotsOnly() so that a snapshot coordinate never shadows a release.
Where the alternative approach differs: a JVM-only library or a hand written parser
The realistic alternatives to this library are a JVM-only CSV library and a parser you write yourself. The difference in approach is in the abstraction layer, not in the format handling.
A JVM-only library exposes java.io types in its signatures, so a caller on Kotlin/JS or Kotlin/Native cannot use the same call at all, and the same codebase ends up with two CSV paths. kotlin-csv puts kotlinx.io at the centre: Path, Source and Sink are the common interface, and java.io.File and java.io.InputStream appear only as JVM-specific overloads. A multiplatform project therefore keeps one call shape and one dialect object across targets.
A hand written parser, usually something splitting lines and then splitting on a comma, has the opposite trade-off. It is short, it has no dependency, and it is wrong in the cases this library handles deliberately: quoted fields containing the delimiter, embedded newlines, doubled quotes, and rows whose length differs from the first. The value of a dedicated implementation here is not speed, since the documentation offers no performance numbers, but consistent handling of the awkward cases and the ability to say what happens when a file is malformed instead of silently producing shifted columns.
Editorial conclusion
kotlin-csv suits Kotlin Multiplatform codebases that need one CSV implementation across JVM, JS, Wasm and Native, and teams willing to pin behaviour with an explicit CsvDialect and row-length policies. It does not suit JVM-only services, where a library already on the classpath is cheaper, nor work that needs a documented charset story on non-JVM targets. Before adopting it, run one real file with mismatched row lengths through the reader and check which of ERROR, IGNORE or TRIM you actually want, because the defaults stop the parse.
Frequently asked questions
How do I add kotlin-csv to a Kotlin Multiplatform project?
Declare the multiplatform coordinate com.jsoizo:kotlin-csv:2.0.0 in Gradle and the JVM, JS, Wasm and supported Native variants resolve from it. A single-platform project can instead depend on a platform artifact such as kotlin-csv-jvm or kotlin-csv-js.
How does kotlin-csv handle rows with a different number of fields?
Both excessFieldsRowBehaviour and insufficientFieldsRowBehaviour default to ERROR, so a length mismatch throws CsvFieldNumDifferentException. The reader can be configured to IGNORE or TRIM extra fields, and to IGNORE or fill with an empty string for missing ones.
What is the difference between an empty field and a null value in kotlin-csv?
The reader option nullFieldIndicator decides which empty fields the nullable APIs expose as null, with NEITHER as the default and EMPTY_SEPARATORS, EMPTY_QUOTES and BOTH as the alternatives. On the writer side a null is emitted as an unquoted empty field, so both sides must be configured for nulls to survive a round trip.
Does kotlin-csv support reading a file with a non-default character set?
Charset is passed as an argument on the I/O call, and the documentation states this is JVM-only. No portable charset option is described for the JS, Wasm or Native targets.
How do I read only the first rows of a large CSV file in kotlin-csv?
readFromFile hands you a cold Sequence of rows inside a block that owns and closes the file, so take(n) and similar operations stop early without parsing the rest of the file. Reading a String through readAll is eager and returns a list built up front.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jsoizo-kotlin-csv)Was this useful?