Skip to content
CSVTidy

How to Convert CSV to JSON (And the Type Traps to Avoid)

4 min read

Convert CSV to JSON with Python, JavaScript, jq, or a browser tool — plus the type-inference mistakes that silently destroy ZIP codes and IDs.

Converting CSV to JSON looks trivial: take the header row as keys, take each data row as values, emit objects. Every tutorial shows the same five-line loop.

The loop is easy. What breaks in production is types — and it breaks quietly, which is worse.

The three shapes of "CSV as JSON"

Before converting, decide which shape you actually need.

Array of records — one object per row. What most APIs and JavaScript code expect:

[
  { "name": "Jane", "age": "34" },
  { "name": "John", "age": "41" }
]

Column arrays — one array per field. Compact for charting libraries and dataframes:

{
  "name": ["Jane", "John"],
  "age": ["34", "41"]
}

Array of arrays — header row plus value rows. Smallest on the wire, and what Google Sheets and many table APIs use:

[
  ["name", "age"],
  ["Jane", "34"],
  ["John", "41"]
]

Records are the safe default. Column arrays are meaningfully smaller for wide data because field names aren't repeated per row.

Python

The standard library handles this without dependencies:

import csv, json

with open("data.csv", newline="", encoding="utf-8-sig") as f:
    rows = list(csv.DictReader(f))

with open("data.json", "w", encoding="utf-8") as f:
    json.dump(rows, f, indent=2, ensure_ascii=False)

Two details that matter more than they look:

  • encoding="utf-8-sig" strips a byte-order mark, so your first key is id rather than id.
  • ensure_ascii=False keeps José as José instead of José.

Every value is a string. That's csv being honest — a CSV has no type information.

JavaScript

import Papa from "papaparse";

const result = Papa.parse(csvText, {
  header: true,
  skipEmptyLines: "greedy",
});
const json = JSON.stringify(result.data, null, 2);

Papa Parse offers dynamicTyping: true, which converts numeric-looking strings to numbers. Read the next section before you enable it.

jq, for the command line

jq -R -s -f csv2json.jq data.csv

Honestly, for anything beyond trivial files, reach for Python. jq's string handling makes quoted fields containing commas genuinely painful.

The type trap that costs people real money

Here is the failure that shows up in production, not in tutorials.

Your CSV contains a ZIP code column:

name,zip
Jane,02134
John,90210

Enable automatic type conversion and you get:

[
  { "name": "Jane", "zip": 2134 },
  { "name": "John", "zip": 90210 }
]

02134 became 2134. The leading zero is gone, the value is now a number, and every Boston address in your dataset is wrong. Nothing errored.

The same thing destroys:

| Column type | What breaks | |---|---| | ZIP codes | 021342134 | | Phone numbers | 0044...44... | | Product SKUs | 0077 | | Account numbers | leading zeros dropped | | Credit card BINs | leading zeros dropped | | Very long IDs | 12345678901234567890 loses precision past 2⁵³ |

That last one is subtle and vicious. JavaScript numbers are IEEE-754 doubles, so any integer above 9007199254740991 cannot be represented exactly:

JSON.parse('{"id": 12345678901234567890}').id
// 12345678901234567000  ← silently wrong

The rule: convert to a number only when the value is a quantity you would do arithmetic on. Identifiers stay strings — even when they look numeric. If you would never add two of them together, it is not a number.

Handling nested data

CSV is flat and JSON is not, so decide how to bridge them.

Going JSON → CSV, flatten with dot notation:

{ "user": { "name": "Jane", "address": { "city": "Boston" } } }

becomes columns user.name and user.address.city.

Arrays inside records have no good flat representation. Serializing them as JSON text in the cell is the least-bad option — it's ugly, but nothing is lost:

tags
"[""red"",""blue""]"

Going CSV → JSON, you can rebuild nesting from dotted headers, but only if you control the header naming convention.

Empty cells: null, empty string, or absent?

A CSV cannot distinguish "no value" from "empty text". You have to choose:

{ "middle_name": null }    // explicitly no value
{ "middle_name": "" }      // present but empty
{ }                        // key omitted entirely

null is usually right for databases; empty string is safer for systems that choke on nulls; omitting the key breaks consumers expecting a fixed shape. Pick one and apply it consistently — mixing them across rows is what causes the downstream bug.

Checklist

  • Choose the shape: records, columns, or arrays.
  • Read with utf-8-sig to drop the byte-order mark.
  • Keep identifiers as strings; convert only genuine quantities.
  • Watch for integers above 2⁵³ — they lose precision as JSON numbers.
  • Decide on one empty-value convention and stick to it.
  • Validate the output parses before shipping it anywhere.

Keep reading