CSV to JSON
Convert CSV to JSON without turning zip codes, SKUs, and version numbers into the wrong numbers.
output appears hereCSV 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.
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 -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.
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.
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.