NV ToolkitFormat · Validate · Convert
Tutorial

How to Convert CSV to JSON in JavaScript, Python and the Browser

Turning a CSV file into JSON sounds like a two-line job, split on newlines and then on commas, and that version is wrong the moment a field contains a comma. This tutorial uses real parsers: papaparse in Node and the browser, and the standard csv module in Python. Every snippet was run against the same sample file, and the later sections cover the parts that take the real time: types, nesting, large files, and the trip back to CSV.

In short

Converting CSV to JSON safely means using a real parser, not split(','): papaparse in Node and the browser, or Python's csv.DictReader, both handle quoted commas, embedded newlines and doubled quotes correctly. Every CSV value starts as a string, so type inference (numbers, booleans, nulls) is a separate, optional step that must be told to leave IDs with leading zeros alone.

By NaveenKumar T · Software engineer · builds and maintains NV Toolkit Published 2026-09-10 7 min read

What makes CSV harder than it looks

CSV has no type system and only one widely followed specification, RFC 4180, which many producers ignore. Four things break naive converters. Quoted fields: a value containing a comma, a double quote or a line break must be wrapped in double quotes, and a literal quote inside it is written as two quotes. Embedded newlines: a quoted field may span lines, so "one line equals one record" is false.

Encoding markers: Excel writes a byte-order mark (the bytes EF BB BF) at the start of UTF-8 files, and it ends up inside the first header name if the reader does not strip it, giving a key like "\ufeffid". Delimiters: in locales where the comma is the decimal separator, Excel writes semicolons between fields, and tab-separated exports are common.

Then there is typing. Every CSV value is text, so 1, 1.0, true and an empty cell are all strings until something decides otherwise. A converter that infers types will turn 01234 into 1234 and a 19-digit order ID into a floating-point approximation unless told not to. The sample below contains a quoted comma, a doubled quote, a note that spans two lines and an empty price; it is the input for every example that follows.

IssueExampleFix
Quoted field with a comma"Austin, TX"Use a real parser; commas inside quotes aren't delimiters
Doubled quote inside a quoted field"""perfect"""Two quotes decode to one literal quote
Embedded newline"Ships in\ntwo packages"A quoted field may span lines; don't split on newlines first
UTF-8 byte-order mark\ufeffid as the first headerRead with encoding="utf-8-sig" or let papaparse strip it
Locale decimal comma12,50 for twelve and a halfUse a transform step; no parser guesses this
csv
id,name,city,price,in_stock,note
1,Keyboard,"Austin, TX",149.99,true,"Customer said ""perfect"""
2,Mouse,Berlin,39.5,false,"Ships in
two packages"
3,Monitor,Pune,,true,

Node.js and the browser with papaparse

papaparse — the engine behind our CSV to JSON converter — is the most widely used CSV parser for JavaScript and runs unchanged in Node and browsers. Three options do most of the work. header: true turns the first row into object keys. dynamicTyping: true converts values that look like numbers or booleans and turns empty cells into null. skipEmptyLines: true ignores the trailing newline that almost every file ends with; without it the parser emits a final row of { id: "" }, a bug that surfaces only when something downstream chokes on the phantom record.

The delimiter is detected automatically and reported in meta.delimiter, so semicolon and tab files need no special handling, and a leading byte-order mark is stripped before the header is read.

In the browser the call is identical except for the input: pass a File object from an <input type="file"> and a complete callback instead of reading the result synchronously, as in Papa.parse(file, { header: true, dynamicTyping: true, skipEmptyLines: true, complete: (r) => render(r.data) }). papaparse reads the file in chunks with a FileReader, so a multi-hundred-megabyte upload does not need to be loaded into a string first.

javascript
import { readFileSync } from "node:fs";
import Papa from "papaparse";

const text = readFileSync("products.csv", "utf8");

const { data, errors, meta } = Papa.parse(text, {
  header: true,        // first row becomes the keys
  dynamicTyping: true, // "149.99" -> 149.99, "true" -> true, "" -> null
  skipEmptyLines: true // ignore the trailing newline most files end with
});

if (errors.length) console.error(errors);
console.log(meta.delimiter === "," ? "comma-delimited" : `delimiter: ${meta.delimiter}`);
console.log(JSON.stringify(data, null, 2));

The JSON both produce, and the typing rules behind it

The quoted comma stayed inside city, the doubled quote became a single one, the two-line note kept its newline (serialised as \n in JSON), and the empty price became null. This is the output from the Node snippet above and, with the coercion function shown in the next section, from Python as well.

papaparse's typing rules are narrower than they look, which is mostly good news. A value becomes a number only if it matches a plain decimal or exponent pattern and its magnitude is below 2^53, so a 16-digit order ID such as 9007199254740993 stays a string instead of being rounded. A bare date like 2026-09-10 stays a string; only a full ISO 8601 timestamp with a timezone, such as 2026-09-10T10:00:00Z, becomes a JavaScript Date, which JSON.stringify then writes back as an ISO string. Leading zeros are the one real casualty: 01234 becomes 1234. Exempt such columns by passing an object instead of true, for example dynamicTyping: { zip: false, order_id: false }, or a function that returns true only for the columns you want converted.

Locale decimal commas are the other trap. A German or French export writes 12,50 for twelve and a half, and no parser will guess that; papaparse leaves it as the string "12,50". Handle it with the transform option, which runs on every cell before typing: transform: (value, field) => field === "price" ? Number(value.replace(",", ".")) : value. Python needs the equivalent float(value.replace(",", ".")) in its coercion step.

json
[
  {
    "id": 1,
    "name": "Keyboard",
    "city": "Austin, TX",
    "price": 149.99,
    "in_stock": true,
    "note": "Customer said \"perfect\""
  },
  {
    "id": 2,
    "name": "Mouse",
    "city": "Berlin",
    "price": 39.5,
    "in_stock": false,
    "note": "Ships in\ntwo packages"
  },
  {
    "id": 3,
    "name": "Monitor",
    "city": "Pune",
    "price": null,
    "in_stock": true,
    "note": null
  }
]

Python with csv.DictReader and json.dumps

Python's standard library handles the quoting rules correctly: csv.DictReader yields one dictionary per row keyed by the header, and json.dumps serialises the list. Two arguments to open matter. newline="" hands the raw line endings to the csv module, which needs them to reassemble quoted fields that span lines; without it, a note like the one in the sample can be split across two records on Windows. encoding="utf-8-sig" reads UTF-8 and silently drops a byte-order mark if one is present, so the first key is id rather than \ufeffid.

DictReader does no type conversion at all: every value is a string, including numbers and empty cells. The small coerce function below applies the same rules as papaparse (empty to None, true/false to booleans, numeric text to int or float) with one deliberate improvement: anything with a leading zero that is not a decimal fraction is kept as text, so postal codes and padded IDs survive. Pass ensure_ascii=False to json.dumps to write accented characters directly instead of as \u escapes.

python
import csv
import json


def coerce(value: str):
    """Light type inference; keep anything with a leading zero as text."""
    if value == "":
        return None
    if value.lower() in ("true", "false"):
        return value.lower() == "true"
    if value.startswith("0") and len(value) > 1 and not value.startswith("0."):
        return value  # "01234" is an identifier, not the number 1234
    for cast in (int, float):
        try:
            return cast(value)
        except ValueError:
            pass
    return value


with open("products.csv", newline="", encoding="utf-8-sig") as f:
    rows = [{k: coerce(v) for k, v in row.items()} for row in csv.DictReader(f)]

print(json.dumps(rows, indent=2, ensure_ascii=False))

Nested output: grouping rows into arrays of objects

CSV is flat, so a one-to-many relationship (an order with several line items) is stored as one row per item with the order columns repeated. The JSON a client wants is usually the opposite shape: one object per order with an items array. The conversion is a single pass that keys a Map by the parent identifier, creates the parent object the first time an id appears, and pushes the remaining columns of each row into its array. Because the rows are already typed, the grouped result is ready to serialise.

The same loop works for deeper nesting (customer to orders to items) by grouping twice, and in Python by replacing the Map with a dict and setdefault. If the CSV was exported from a spreadsheet with dot-path headers such as address.city, split each key on the dot and assign into nested objects instead; that is the inverse of the flatten function shown in the last section.

javascript
import Papa from "papaparse";

const csv = `order_id,customer,sku,qty
1001,Ada,KB-75,1
1001,Ada,MS-12,2
1002,Grace,MN-27,1`;

const rows = Papa.parse(csv, { header: true, dynamicTyping: true, skipEmptyLines: true }).data;

const byOrder = new Map();
for (const { order_id, customer, ...line } of rows) {
  if (!byOrder.has(order_id)) byOrder.set(order_id, { order_id, customer, items: [] });
  byOrder.get(order_id).items.push(line);
}
console.log(JSON.stringify([...byOrder.values()], null, 2));
// [{ "order_id": 1001, "customer": "Ada",
//    "items": [{ "sku": "KB-75", "qty": 1 }, { "sku": "MS-12", "qty": 2 }] },
//  { "order_id": 1002, "customer": "Grace", "items": [{ "sku": "MN-27", "qty": 1 }] }]

Large files: stream rows instead of loading the file

Reading a multi-gigabyte CSV into a string and then building one giant array will exhaust memory twice over. papaparse accepts a Node readable stream and calls a step function once per parsed row, so memory use stays flat no matter how large the input. The natural output for this mode is newline-delimited JSON (one object per line, no enclosing array), which is what most log pipelines, BigQuery and jq expect; a standard JSON array would require writing the opening bracket, a comma between every row and the closing bracket by hand.

In Python the equivalent is simply iterating the DictReader and writing json.dumps(row) followed by a newline for each; DictReader is already lazy, so nothing extra is needed. In both languages, avoid collecting rows for grouping unless the parent identifier is sorted in the file; a sorted file can be grouped in a stream by flushing the current parent whenever the id changes.

javascript
import { createReadStream, createWriteStream } from "node:fs";
import Papa from "papaparse";

const out = createWriteStream("orders.ndjson");
let rows = 0;

Papa.parse(createReadStream("orders.csv"), {
  header: true,
  dynamicTyping: true,
  skipEmptyLines: true,
  step: ({ data }) => {
    out.write(JSON.stringify(data) + "\n"); // one JSON object per line
    rows++;
  },
  complete: () => {
    out.end();
    console.log(`wrote ${rows} rows`);
  },
});

Going back: JSON to CSV and flattening nested keys

The reverse trip — like our JSON to CSV converter — is Papa.unparse in JavaScript and csv.DictWriter in Python. Both quote and escape correctly: a value containing a comma or a quote is wrapped in double quotes with inner quotes doubled, so the round trip is lossless for strings. Both also need a flat object per row. Nested objects have to be flattened into dot-path columns (address.city, address.zip) and arrays either exploded into extra rows or serialised as a JSON string inside a single cell, which is what the flatten helper below does for tags. Which choice is right depends on who opens the CSV: a spreadsheet user wants columns, an import script may prefer the JSON-in-a-cell form it can parse back.

Python's DictWriter requires the column list up front. Taking the union of all keys across rows (sorted for a stable order) avoids a ValueError when later rows contain keys the first one lacked; missing keys are written as empty cells. When exporting for Excel, remember that it will strip leading zeros and reformat dates on open, so codes and dates that must survive verbatim should be quoted and, if necessary, prefixed with an apostrophe or written as text in the spreadsheet rather than through CSV.

javascript
import Papa from "papaparse";

function flatten(obj, prefix = "", out = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const path = prefix ? `${prefix}.${key}` : key;
    if (value && typeof value === "object" && !Array.isArray(value)) flatten(value, path, out);
    else out[path] = Array.isArray(value) ? JSON.stringify(value) : value;
  }
  return out;
}

const users = [
  { id: 1, name: "Ada", address: { city: "Austin", zip: "78701" }, tags: ["admin", "beta"] },
  { id: 2, name: "Grace", address: { city: "Pune", zip: "411001" }, tags: [] },
];

console.log(Papa.unparse(users.map((u) => flatten(u))));
// id,name,address.city,address.zip,tags
// 1,Ada,Austin,78701,"[""admin"",""beta""]"
// 2,Grace,Pune,411001,[]
How this was tested: Every CSV parse and JSON conversion in this guide was run with papaparse 5.5 in Node.js 22.22 and with Python 3.12 on 2026-09-10.

Sources

  1. RFC 4180 — Common Format and MIME Type for CSV Files
  2. Papa Parse — Documentation
  3. Python documentation — csv module

Frequently asked questions

Why does my JSON have a key like "\ufeffid" instead of "id"?

The file starts with a UTF-8 byte-order mark, which Excel adds so that it can recognise the encoding later. papaparse strips it automatically; in Python, open the file with encoding="utf-8-sig", which reads UTF-8 and drops the mark if present. Any other reader that hands the raw bytes to a CSV parser will leave the invisible character glued to the first header.

How do I stop leading zeros being dropped from postal codes and IDs?

Turn type inference off for those columns. In papaparse pass an object rather than true, for example dynamicTyping: { zip: false }, or a function that returns true only for numeric columns. In Python, decide per column in your coercion step, as the coerce function in this guide does by keeping any value with a leading zero as text. If the zeros are already gone, they were lost in the spreadsheet before export and no converter can restore them.

Should the output be a JSON array or one object per line?

A JSON array is what most APIs and front-end code expect and is the right choice for anything that fits comfortably in memory. Newline-delimited JSON (NDJSON) is better for large exports and pipelines because it can be written and read one row at a time, appended to, and processed with tools like jq without loading everything at once. Both are trivial to produce from the same parsed rows.

Is there a way to convert without writing a script?

Python can do it in one command if it is installed: python -c "import csv,json,sys;print(json.dumps(list(csv.DictReader(open(sys.argv[1],newline='',encoding='utf-8-sig'))),indent=2))" file.csv. Every value will be a string, since DictReader does no typing. For a typed result with no install at all, a browser-based converter running papaparse gives the same output as the Node snippet in this guide.

Drop a file into the CSV to JSON converter to check the output shape and typing before you wire up the code.

Convert a CSV now

Keep working

Related tools