Acquis 24 - CSV

Contents (12)
  1. Parsing CSV
    1. Headers
    2. Quoted fields
    3. Custom delimiter
  2. Serializing CSV
    1. Automatic quoting
    2. Non-string values
    3. Custom delimiter
  3. Error handling
  4. Motivation
    1. Consistency with JSON
    2. No external dependencies

The fromcsv and tocsv functions provide built-in CSV serialization and deserialization, following the same pattern as the existing fromjson and tojson functions.

Parsing CSV#

fromcsv takes a CSV string and returns a table of rows. Each row is a table of string values.

local data = fromcsv("name,age,city\nAlice,30,Paris\nBob,25,London")

assert(#data == 3)
assert(data[1][1] == "name")
assert(data[2][1] == "Alice")
assert(data[2][2] == "30")

Headers#

When the optional headers argument is true, the first row is treated as column headers and each subsequent row is returned as a table keyed by those headers instead of numeric indices.

local data = fromcsv("name,age,city\nAlice,30,Paris\nBob,25,London", true)

assert(#data == 2)
assert(data[1].name == "Alice")
assert(data[1].age == "30")
assert(data[1].city == "Paris")
assert(data[2].name == "Bob")

Quoted fields#

Fields containing commas, newlines, or double quotes can be enclosed in double quotes. A literal double quote inside a quoted field is escaped by doubling it, per RFC 4180.

local data = fromcsv('"hello, world","she said ""hi"""')

assert(data[1][1] == "hello, world")
assert(data[1][2] == 'she said "hi"')

Custom delimiter#

An optional third argument specifies the field delimiter. The default is ,.

local data = fromcsv("name\tage\nAlice\t30", false, "\t")

assert(data[1][1] == "name")
assert(data[2][2] == "30")

Serializing CSV#

tocsv converts a table of rows to a CSV string. Each row is a sequential table of values.

local csv = tocsv({
    {"name", "age", "city"},
    {"Alice", "30", "Paris"},
    {"Bob", "25", "London"},
})

assert(csv == "name,age,city\nAlice,30,Paris\nBob,25,London\n")

Automatic quoting#

Fields that contain the delimiter, newlines, or double quotes are automatically quoted. Double quotes within fields are escaped by doubling them.

local csv = tocsv({{"hello, world", 'say "hi"'}})

assert(csv == '"hello, world","say ""hi"""\n')

Non-string values#

Non-string values are converted to their string representation via tostring before serialization.

local csv = tocsv({{1, true, 3.14}})

assert(csv == "1,true,3.14\n")

Row length follows the # operator, so an embedded nil may truncate the row at the border. Use empty strings for empty fields:

local csv = tocsv({{"a", "", "c"}})

assert(csv == "a,,c\n")

Custom delimiter#

An optional second argument specifies the field delimiter. The default is ,.

local csv = tocsv({{"a", "b"}, {"c", "d"}}, "\t")

assert(csv == "a\tb\nc\td\n")

Error handling#

Both functions raise errors on malformed input:

local ok, err = catch fromcsv(true)       -- not a string
assert(not ok)

local ok, err = catch fromcsv('"unterminated') -- unclosed quote
assert(not ok)

local ok, err = catch tocsv("not a table") -- not a table
assert(not ok)

Note that numbers are accepted wherever strings are: fromcsv(42) parses the string "42", following the standard string coercion rules.


Motivation#

CSV is one of the most common data interchange formats. Like JSON, it appears frequently enough in practical programming that built-in support eliminates the need for external dependencies.

Consistency with JSON#

Lus already provides fromjson and tojson as global functions for the most common serialization format. CSV is the natural complement: it covers the tabular data use case that JSON handles awkwardly.

-- Reading a CSV file is as simple as reading JSON
local data = fromcsv(io.open("data.csv"):read("*a"), true)
for _, row in ipairs(data) do
    print(row.name, row.age)
end

No external dependencies#

Without built-in support, CSV parsing requires either pulling in a library or writing a parser manually, both of which are error-prone, especially around edge cases like quoted fields with embedded newlines and escaped quotes. A built-in implementation following RFC 4180 handles these correctly.