JSON.stringify(parse(text, { columns: true, bom: true }), null, 2) turns CSV text into a JSON array of objects, with parse from the csv-parse package. Node has no CSV parser built in, and CSV is harder than it looks, so use a real one. columns: true keys each row by the header row; bom: true drops the byte order mark Excel writes at the start of a UTF-8 file.
npm install csv-parse
import { readFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
const text = readFileSync('people.csv', 'utf8');
const rows = parse(text, { columns: true, bom: true });
console.log(JSON.stringify(rows, null, 2));
For this people.csv:
id,name,city,zip
1,"Lovelace, Ada",London,02134
2,Kurt Gödel,Wien,1090
3,"Alan ""Al"" Turing",,
csv-parse 7.0.3 on Node 22 prints:
[
{
"id": "1",
"name": "Lovelace, Ada",
"city": "London",
"zip": "02134"
},
{
"id": "2",
"name": "Kurt Gödel",
"city": "Wien",
"zip": "1090"
},
{
"id": "3",
"name": "Alan \"Al\" Turing",
"city": "",
"zip": ""
}
]
The converter below gives the same output with its typing box unticked.
Why not split(‘,’)
The split you’d write in a minute reads the simple row right and the other two wrong:
const [head, ...lines] = text.trim().split('\n');
const keys = head.split(',');
const rows = lines.map((line) => Object.fromEntries(line.split(',').map((cell, i) => [keys[i], cell])));
// {"id":"1","name":"\"Lovelace","city":" Ada\"","zip":"London","undefined":"02134"}
// {"id":"3","name":"\"Alan \"\"Al\"\" Turing\"","city":"","zip":""}
The comma inside "Lovelace, Ada" splits the name in two and shifts every later cell, and the quotes stay in the text, doubled ones included. A quoted line break would split a row in two as well. RFC 4180 allows all three, and spreadsheets write them.
Gotchas
cast: truechanges values. It turns numeric-looking cells into numbers, soparse(text, { columns: true, cast: true })gives{"id":1,"name":"Lovelace, Ada","city":"London","zip":2134}: the zip code lost its zero. With typing ticked, the tool keeps"02134"a string and makes1and1090numbers. Acastfunction lets you choose per column.- Without
bom: true, a file saved as Excel’s “CSV UTF-8” keeps the mark in the first key, sorows[0].idisundefinedwhile the key prints asid. - Ragged rows throw. A row with an extra field fails with
CSV_RECORD_INCONSISTENT_COLUMNS(Invalid Record Length: columns length is 2, got 3 on line 2).relax_column_count: truereads on, but drops the extra field. The tool keeps it ascolumn3and warns.
Big files
The stream API reads one record at a time. This writes JSON Lines:
import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';
const parser = createReadStream('people.csv').pipe(parse({ columns: true, bom: true }));
for await (const row of parser) {
process.stdout.write(JSON.stringify(row) + '\n');
}
// {"id":"1","name":"Lovelace, Ada","city":"London","zip":"02134"} ...
In the browser
Papa Parse 5.7 does the same with Papa.parse(text, { header: true }), but it reads the file’s final newline as one more row, {"id":""}, unless you add skipEmptyLines: true. Its dynamicTyping: true also turns 02134 into 2134.