CSV to JSON in Node.js

JSON.stringify(parse(text, { columns: true, bom: true }), null, 2)

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: true changes values. It turns numeric-looking cells into numbers, so parse(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 makes 1 and 1090 numbers. A cast function lets you choose per column.
  • Without bom: true, a file saved as Excel’s “CSV UTF-8” keeps the mark in the first key, so rows[0].id is undefined while the key prints as id.
  • 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: true reads on, but drops the extra field. The tool keeps it as column3 and 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.

Try it in the CSV ↔ JSON Converter

JSON

3 rows × 4 columns · delimiter comma · everything runs in this page, nothing is uploaded.

Preview · 4 rows
#idnamecityzip
21Lovelace, AdaLondon02134
32Kurt GödelWien1090
43Alan "Al" Turing

Runs in your browser. Nothing you enter is sent anywhere.