Skip to content

When the CSV Writer Took Off Its Ankle Weights

Editorial illustration of a CSV writer pipeline sending rows into a buffered byte sink
CSV rows move faster when the writer stops taking the long way through character buffers.

The previous CSV post was about reading. bluetape4k-csv stopped dragging every UTF-8 field through a character-first lexer and let Okio scan source segments directly.

That left an obvious follow-up question: if reading got faster by respecting bytes and segments, why was writing still politely handing every file export to OutputStreamWriter?

The writer optimization cleaned up that path. The public API already had the right shape:

suspend fun writeFile(
path: Path,
encoding: Charset = Charsets.UTF_8,
append: Boolean = false,
skipHeaders: Boolean = false,
headers: List<String> = emptyList(),
rows: Flow<Iterable<*>>,
): Long

Yes, FlowCsvWriter is coroutine-based. That matters. The writer does not need to collect a whole export into memory. It can collect one row from a Flow, write that row, check cancellation, and move on.

CSV writer throughput comparison chart showing existing Writer and Okio BufferedSink ops per second
The Okio CSV writer follow-up: Flow rows, UTF-8 sinks, behavior locks, and a 3.25x large export win.

The streaming shape was already there. The expensive-looking part was below it:

OutputStreamWriter(FileOutputStream(path.toFile(), append), encoding).use { fw ->
val fileWriter = DelimitedWriter(fw, delimiter, quote, settings.quoteEscape, lineSeparator)
rows.collect { row ->
currentCoroutineContext().ensureActive()
writeRowToDelimited(fileWriter, fw, row)
count++
}
}

This code is not bad. It is correct, portable, and keeps charset support simple. But for the common UTF-8 file export, it routes delimiter, quote, line separator, and payload text through a character writer even though the destination is ultimately bytes.

The new rule is simple:

Output encodingWriter path
UTF-8Okio BufferedSink fast path
Non-UTF-8Existing Writer fallback

The API stays the same. The UTF-8 lane just takes the shorter road.

The coroutine boundary still owns the file export. The difference is the sink inside it:

private suspend fun writeUtf8FileWithOkio(
path: Path,
append: Boolean,
skipHeaders: Boolean,
headers: List<String>,
rows: Flow<Iterable<*>>,
): Long {
var count = 0L
FileOutputStream(path.toFile(), append).sink().buffer().use { sink ->
val fileWriter = OkioDelimitedWriter(sink, delimiter, quote, settings.quoteEscape, lineSeparator)
if (!skipHeaders && headers.isNotEmpty()) {
writeRowToOkio(fileWriter, headers)
}
rows.collect { row ->
currentCoroutineContext().ensureActive()
writeRowToOkio(fileWriter, row)
count++
}
}
return count
}

The important line is not the Okio import. It is the structure around it: rows.collect, then ensureActive(), then one row into a buffered sink. This keeps the export pipeline responsive to cancellation while avoiding a detour through Writer.write(...) for UTF-8 files.

CSV writing sounds boring until the first empty string, leading space, embedded quote, CRLF field, or TSV variant walks in. The fast path could not be a “join with commas” shortcut.

The internal writer keeps the existing DelimitedWriter semantics:

private fun writeQuoted(s: String) {
sink.writeUtf8Char(quote)
var start = 0
for (index in s.indices) {
if (s[index] == quote) {
if (start < index) {
sink.writeUtf8(s, start, index)
}
sink.writeUtf8Char(quoteEscape)
sink.writeUtf8Char(quote)
start = index + 1
}
}
if (start < s.length) {
sink.writeUtf8(s, start, s.length)
}
sink.writeUtf8Char(quote)
}

That chunked write is the small trick. Most field text goes to the sink in slices. Only quote characters become doubled quote bytes. The writer does not walk every normal character through a tiny call.

The behavior locks cover the cases that usually make CSV fast paths regret their confidence:

CaseExpected behavior
nullunquoted empty field
""quoted empty string
leading or trailing spacequoted
delimiter inside fieldquoted
embedded quotedoubled quote
CR/LF inside fieldquoted
quoteAllevery non-null field quoted
TSV modetab delimiter preserved

Benchmark command:

Terminal window
./gradlew :bluetape4k-csv:testBenchmark

Measured writer workloads:

DatasetExisting WriterOkio writerSpeedup
writerBaseline_small vs okioWriter_small11,741.418 ops/s16,355.656 ops/s1.39x
writerBaseline_medium vs okioWriter_medium676.110 ops/s2,068.696 ops/s3.06x
writerBaseline_large vs okioWriter_large83.802 ops/s272.157 ops/s3.25x

The small workload improves, but setup and coroutine collection overhead still show through. The medium and large workloads tell the real story. Once rows keep arriving, fewer character-layer writes and fewer tiny calls become visible.

The reader optimization used BufferedSource and UnsafeCursor. The writer does not need UnsafeCursor, because it is not searching through existing segments. It is producing bytes.

So the reusable lesson was not “always use the sharpest Okio API.” It was narrower:

DirectionUseful Okio layer
ReadBufferedSource, Buffer, read-only UnsafeCursor for structural byte scanning
WriteBufferedSink for direct UTF-8 row output

The same design rule applies in both directions: keep the public API stable, keep fallback paths for unsupported cases, and lock behavior with examples that include awkward CSV data.

This was the good kind of optimization: no new public concept, no new way to write CSV, and no special API for users to remember. FlowCsvWriter already promised a streaming pipeline. The patch just removed one layer of ankle weights from the UTF-8 file path.

Comments

Leave a note or reaction with your GitHub account.