SagaSaga
SagaCsv

Writing CSV

All writes start with writer (), which returns a Config with the same defaults as reader (). Configure it, then call write or write_keyed. The result is a single String (a BEAM binary) you can pipe to File.write! or send over the wire.

Positional rows with write

let csv =
  writer ()
  |> write [["a", "b"], ["c", "d"]]
# "a,b\nc,d\n"

Every row terminates with \n, including the last one. Fields are written verbatim unless they need quoting (see below).

Adding a header row

with_headers prepends a row to the output. It does not affect how write interprets the data — it just adds a row up front:

let csv =
  writer ()
  |> with_headers ["name", "age"]
  |> write [["Alice", "30"], ["Bob", "25"]]
# "name,age\nAlice,30\nBob,25\n"

If you call with_headers with [] (the default), no header row is added. Use this when the consumer will supply its own column names.

Keyed rows with write_keyed

When your data is List (Dict String String), use write_keyed plus with_headers to project the dicts into a fixed column order:

let alice = Dict.new () |> Dict.put "name" "Alice" |> Dict.put "age" "30"

let csv =
  writer ()
  |> with_headers ["name", "age"]
  |> write_keyed [alice]
# "name,age\nAlice,30\n"

with_headers is mandatory here — write_keyed panics if you forget it, because there is no other way to know the column order. Keys missing from a dict are written as empty strings; keys present in the dict but not in with_headers are silently dropped.

Custom separators

writer ()
|> with_separator "\t"
|> write [["a", "b"], ["c", "d"]]
# "a\tb\nc\td\n"

Same one-byte ASCII constraint as on the read side.

What gets quoted

csv-core quotes the minimum necessary. A field is wrapped in "..." when it contains the delimiter, a ", or a newline. Embedded quotes are doubled:

writer () |> write [["hello, world", "ok"]]
# "\"hello, world\",ok\n"

writer () |> write [["say \"hi\"", "ok"]]
# "\"say \"\"hi\"\"\",ok\n"

writer () |> write [["line1\nline2", "ok"]]
# "\"line1\nline2\",ok\n"

You never need to pre-escape fields yourself — doing so will produce double-escaped output.

Round-tripping

write followed by parse (with skip_header if you used with_headers) returns the original data unchanged:

let data = [["Alice", "30"], ["Bob", "25"]]

let csv =
  writer ()
  |> with_headers ["name", "age"]
  |> write data

let rows =
  reader ()
  |> skip_header
  |> parse csv
# rows == data

The same holds for write_keyed + parse_keyed — see the round-trip tests in tests/csv_test.dy for the full picture.