TabOnly

CSV to JSON

Convert CSV to JSON without turning zip codes, SKUs, and version numbers into the wrong numbers.

input
output
output appears here

CSV has text, not types

The parse itself is a solved problem — papaparse handles RFC 4180 quoting, embedded commas, embedded newlines, CRLF, BOMs, and multiple delimiters correctly, and this page doesn't reimplement any of that. What it gets wrong by default is the one decision that actually matters: what a cell's text means. Measured this session, one row of ordinary business data run through papaparse's automatic type inference: an ID 007 becomes the number 7, a zip code 01234 becomes 1234, and a version 1.10 becomes 1.1 — three of seven columns corrupted, each in a way that looks completely plausible in a spot check and breaks a join or a display three steps later. This page keeps every column as text by default, and where you turn typing on, the notes strip says exactly which values in that column would change first, so the corruption is a choice you make on purpose rather than a default you didn't notice.

Ragged rows, malformed quotes, duplicate headers, and BOMs are all handled without losing the rows that did parse. A row with too many or too few fields compared to the header is reported with its row number, and the rest of the file still converts — a CSV with three broken rows out of ten thousand still yields the other 9,997. A repeated header name (a,a,b) is renamed by the parser to a_1 rather than silently overwritten, and this page surfaces that rename in the notes strip rather than letting a_1 show up unexplained in your JSON. The delimiter — comma, semicolon, tab, or pipe — is auto-detected rather than assumed, which matters for a European-locale Excel export that uses semicolons because the comma is already the decimal separator there.

One thing CSV genuinely cannot do is distinguish null from an empty string — both are just an empty cell, and no amount of clever parsing recovers the difference the format destroyed. This page's null-marker control treats a specific token (\N, the Postgres convention for a literal null in a CSV export) as JSON null when it's present, which is an opt-in convention, not a general fix — if your source didn't write that token, the distinction is already gone by the time you have a CSV file at all.

Do it without this tool

Same conversion, your own tools. The jq one-liner is the version to read the caveat on — it does not handle a quoted field containing a comma, which is exactly the case a real CSV library exists to cover.

python · csv.DictReader, everything stays text
import csv, json

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

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

# DictReader keeps every field as a str — no automatic type inference,
# so "007" stays "007". Cast columns explicitly, per column, only where
# you actually want a number or boolean.
jq · the honest version, and its real limit
jq -R -s '
  split("\n") | map(select(length > 0) | split(",")) |
  .[0] as $header | .[1:] | map([$header, .] | transpose | map({(.[0]): .[1]}) | add)
' data.csv

# split(",") does not understand quoted fields — a comma inside a quoted
# value breaks this. It's fine for a simple export; it is not RFC 4180.
# For anything with quoted commas or embedded newlines, use a real parser.
shell · miller, if it's already installed
mlr --icsv --ojson --ifs ';' cat data.csv > data.json
# --ifs ';' sets the input field separator explicitly for a
# semicolon-delimited file — Miller does not auto-detect the delimiter
# the way papaparse (and this page) do.
node · papaparse with an explicit delimiter
import Papa from 'papaparse';

const { data, errors } = Papa.parse(csvText, {
  header: true,
  delimiter: ';',        // omit this to let papaparse auto-detect instead
  dynamicTyping: false,  // the library's own default — keep it false
});
// errors carries row-numbered TooFewFields/TooManyFields diagnostics;
// data still has every row that DID parse, even when errors is non-empty.

What RFC 4180 does and doesn't specify

RFC 4180 §2 defines CSV's record structure — CRLF line endings, comma delimiters, and doubled-quote escaping — and papaparse implements it exactly, which is why none of that is a differentiator worth claiming here. What the RFC does not do is give CSV a type system: a cell's text is just text, and every decision this page makes — text by default, typing as an explicit opt-in, a null marker as an explicit convention — exists precisely because the format itself is silent on what a value means. That silence is also why the a_1 renaming and the __parsed_extra marker exist: they're papaparse's own answers to questions RFC 4180 doesn't address, surfaced here rather than hidden.

FAQ

Why did my leading zeros vanish from an ID column?

Because something turned on automatic type inference, and 007 read as a number is 7 — there is no way to write a number back out with the zeros restored, since a number never carried them in the first place. Measured against papaparse 5.6.0: a zip code 01234, an ID 007, and a version 1.10 are all corrupted by automatic typing, silently and in a way a spot check on the first few rows won't catch. This page defaults every column to text for exactly that reason. The “type values” control makes typing an explicit choice, and before you turn it on for a column, the notes strip already tells you which values in it would lose formatting if you did — so you find out before it happens, not after a join breaks three steps downstream.

Why did 1.10 become 1.1?

Because a number has no concept of a trailing zero — 1.10 and 1.1 are the identical floating-point value, and once the text is converted to a number, the original formatting is gone for good; there is no way to convert back and recover it. This is the same class of loss as the leading-zeros question above, just on the decimal side instead of the integer side, and it's just as silent: the column looks fine in a spot check because 1.1 still looks like a plausible version number. This page's notes strip flags any column where typing would change a value like this before you apply it, specifically so a version string doesn't quietly lose precision on its way into JSON.

What is a_1 in the output?

It's papaparse's own name for a repeated column header, not something this page invented or a name that existed in your file. Measured: a header row of a,a,b comes back as fields ["a", "a_1", "b"] — the parser detects the collision and renames the second occurrence rather than silently overwriting the first column's data with the second's. That's better behavior than overwriting, but it does mean a_1 shows up in your JSON as a real key, which is easy to mistake for a name in the source file. When that happens, the notes strip says which original header was renamed, so you can tell from the message alone that it's a duplicate-header artifact rather than a column you're missing context for.

What happened to a row with too many — or too few — fields?

It's reported with its row number and the rest of the file still converts — a ragged CSV doesn't fail the whole conversion. Measured: a row with an extra field beyond the header count gets flagged as papaparse's TooManyFields, and internally the library tags the overflow with a __parsed_extra key; this page strips that marker out before the JSON is built, since it's an implementation detail of the parser, not a field your source data had, and it would look like a stray property if it reached the output. A row with too few fields is TooFewFields, and the columns it did supply values for are still there — a partially populated object, not a dropped row.

Why did a semicolon-delimited file parse correctly without me telling it the delimiter?

This page auto-detects the delimiter rather than assuming a comma, which matters because a semicolon-delimited export out of a European-locale Excel is common — Excel there uses the comma as the decimal separator, so it can't also use it as the field separator. Measured: papaparse correctly distinguishes commas, semicolons, tabs, and pipes, and gets it right even when a quoted field contains the delimiter character itself — "1;x";2 detects ; as the real delimiter and still reads two fields, not three. There's no override control on this page if the auto-detection ever guesses wrong on an unusual file; if you hit that case, one of the snippets above lets you pass a delimiter explicitly.

How do I get nested objects out of dotted column names?

Turn on “expand dotted paths.” It's off by default, on purpose: a header genuinely named user.name — someone's literal column title, not a flattening artifact — is at least as common as a header that means “nest this,” and guessing wrong would silently restructure data you didn't ask to have restructured. With the option on, a header like user.address.city or items[0].sku rebuilds into the equivalent nested JSON path, which is the exact inverse of what json-to-csv does to flatten a nested document into columns in the first place — the two pages are designed as a matched pair for that reason.

related tools