Convert
Convert CSV to JSON on Mac (Without Losing Types)
The shortest correct way to convert CSV to JSON on a Mac uses the Python that is already installed:
python3 -c 'import csv,json,sys; json.dump(list(csv.DictReader(sys.stdin)), sys.stdout)' \
< orders.csv > orders.jsonThat runs in 0.07 seconds, handles quoted commas, doubled quotes and embedded newlines correctly, and emits every value as a string. The last part is not a limitation. It is the only defensible default, and the reason most other recipes corrupt data.
Why is every value a string?
Because a CSV file has no type system. There is no bit anywhere in the format that distinguishes the number 123 from the three characters 1, 2, 3. Every converter that gives you a JSON number has guessed, and the guess is wrong often enough to matter.
Our test CSV was written to make the guessing visible. Eleven columns, four rows, and every value that a type-inferring parser handles badly:
| CSV value | If inferred | What you actually wanted |
|---|---|---|
| 00123 | 123 — zeros gone | The string "00123" |
| 01002 | 1002 — a broken ZIP code | The string "01002" |
| 9007199254740993 | 9007199254740992 in JS | Exact — keep it a string |
| 12345678901234567890 | 1.2345678901234567e+19 | Exact — keep it a string |
| 4.50 | 4.5 — currency precision lost | The string "4.50" |
| 1e3 | 1000.0 | Depends. Ask first |
| +4915112345678 | Not a number; maybe null | A phone number, string |
| 0x1F | Not a number; maybe 31 | An external ID, string |
| null / NaN / TRUE | JSON null, NaN, true | Literal text, probably |
| (empty) | null, or 0, or "" | You have to decide |
The third row is the one that gets people fired. In Node v26.7.0:
> JSON.parse('{"ext_id":9007199254740993}').ext_id
9007199254740992The last digit changed, silently, at parse time. IEEE 754 doubles run out of integer precision at 253, and every JSON number in JavaScript is a double. Keep that column a string and it is exact. Python fares better on integers — it has arbitrary precision — but float('12345678901234567890') still returns 1.2345678901234567e+19, and 1e3 parses to 1000.0, a float where your source said something else.
Why does the jq recipe corrupt my data?
Because jq has no CSV reader. It can write CSV with @csv, but reading one means splitting text by hand, and the recipe that circulates does exactly that:
jq -Rsn '[inputs | split("\n") | .[1:] | map(split(","))]' orders.csvIt exits 0 and produces valid JSON. Here is what it did to the row containing "Ships Tue, then invoices":
[
"1", "00123", "3", "19.99", "true", "0.5", "01002",
"9007199254740993",
"\"Ships Tue", ← split at the comma inside the quotes
" then invoices\"", ← and now every later column is off by one
"2026-03-04",
"\r" ← the CR from the CRLF line ending
]Two separate bugs. split(",") does not know that a comma inside a quoted field is data, so the row gained a column and everything after it shifted. And split("\n") leaves the carriage return behind, because RFC 4180 specifies CRLF and real exporters emit it — our fixture had five.
An embedded newline would break it a third way, splitting one logical record across two output rows. jq is the right tool for reshaping JSON. It is the wrong tool for parsing CSV, and no amount of regex fixes that. Parse with something that implements the format, then pipe into jq.
Should the output be a JSON array or NDJSON?
NDJSON — one JSON object per line, no enclosing brackets, no commas between records — unless something downstream specifically demands an array. Same four rows, both ways:
| Format | Bytes | Can you read it in chunks? | Append a record? |
|---|---|---|---|
| JSON array, indented | 1,075 | No | Rewrite the file |
| JSON array, compact | 1,074 | No | Rewrite the file |
| NDJSON | 784 | Yes, line by line | Append one line |
The size difference is real but secondary. The property that matters is that every line stands alone. We checked both with jq -e: head -3 of the array file is invalid JSON, because a truncated array is not a document. head -2 of the NDJSON file is two complete records.
That is what lets you stream a 40 GB export through a constant-memory process, resume a failed load at line 3,481,902, and tail -f a file that is still being written. An array forces the whole document into memory before the first record is available. BigQuery, Elasticsearch’s bulk API, ClickHouse and most log pipelines take NDJSON for exactly this reason.
One thing to verify in your own output: a field containing a newline must be escaped, or the one-record-per-line invariant is broken and the format is worthless. Ours was — "note":"Line one\nLine two" stayed on a single physical line.
Converting between the two afterwards is one command each way:
jq -c '.[]' orders.json > orders.ndjson # array → NDJSON
jq -s '.' orders.ndjson > orders.json # NDJSON → arrayHow do you add types and structure afterwards?
Convert to all-strings first, then reshape in one explicit pass. This is where jq is genuinely the right tool, because the cast is visible in the source and reviewable in a diff:
jq '[.[] | {
id: (.id|tonumber),
sku: .sku,
qty: (.qty|tonumber),
price: (.price|tonumber),
active: (.active|ascii_downcase == "true"),
zip: .zip
}]' orders.jsonWhich returned:
{
"id": 1,
"sku": "00123",
"qty": 3,
"price": 19.99,
"active": true,
"zip": "01002"
}id, qty and price are numbers because we said so. sku and zip kept their leading zeros because we did not. If a value in a tonumber column is not numeric, jq fails loudly instead of writing a wrong answer, which is the behaviour you want in a load script.
Nesting is the same idea. A CSV is flat by definition, so any structure has to be imposed after the fact:
jq '[.[] | {
id,
line: { sku, qty: (.qty|tonumber), price: (.price|tonumber) },
meta: { zip, shipped_at }
}]' orders.jsonFor grouping rows into parent objects — many order lines under one order — group_by plus map does it, and that is the point at which most people should stop shell-scripting and write twenty lines in the language the data is going into.
What does a GUI add to this?
Less than usual, and it is worth being specific about what it does. Smol writes both formats from the table converter, and its behaviour is the same one recommended above: an array of flat objects, every value a string, no inference. Our 414-byte fixture became 1,075 bytes of JSON in 31 ms, valid per jq -e, with the quoted comma, the doubled quotes and the embedded newline all handled correctly and the leading zeros intact. NDJSON came out at 784 bytes.
At scale it holds up: 50,000 rows and 2,267,102 bytes converted to NDJSON in 212 ms, producing 4,350,411 bytes with all 50,000 lines present and sku still reading 00000001. The Python stdlib route took 0.44 s on the same input, so this is a genuinely native path rather than a wrapper.
What it does not have is a type-cast option, a header override, a delimiter picker or a nesting rule. Table output covers csv tsv json ndjson html md xlsx numbers, so the useful case is a drawer of files in several shapes that all need to become one — and the md and numbers targets, which neither Python nor jq will write for you. The full format reference lists every target.
For a repeatable pipeline there is a better fit than clicking. Smol ships an MCP server, so Claude Code, Codex and Google Antigravity call the conversion as a tool rather than shelling out. That earns its place when the conversion is one step in a job an agent is already driving — pull the export, convert to NDJSON, load it, compress the artefacts — and not when you are converting one file at a prompt, where python3 -c is quicker to type than anything else on this page.
When is Smol not the answer here?
Whenever the conversion has an opinion in it. Column types, a non-comma delimiter, a file with no header row, grouping rows into nested objects, streaming a file too large to fit in memory, or anything that belongs in version control — all of that wants code, and macOS already ships the code.
- Python 3 is preinstalled.
csv.DictReaderis a correct RFC 4180 parser, it handles CRLF and embedded newlines, and the one-liner in the introduction is the whole solution for most files. - jq is preinstalled on macOS 27 at
/usr/bin/jq. Use it for reshaping JSON, never for parsing CSV. - Miller (
mlr) exists and is better than both for command-line CSV work, withmlr --icsv --ojson cat file.csvas its equivalent.brew install miller.
Where $29 once makes sense is breadth: the same app handling spreadsheets, documents, images, video and audio in one drop, with a Finder Quick Action, on a machine where you would rather not install a toolchain. For the CSV-to-JSON job on its own, the built-in Python wins and this page should say so.
If the CSV came out of a spreadsheet, read what the XLSX to CSV step already did to your leading zeros before you blame the JSON step — the damage usually happened earlier.
How this was measured
MacBook Pro (Mac14,9), Apple M2 Pro, 10 cores, 16 GB, macOS 26A428. Python 3.14.5, Node v26.7.0, jq-1.7.1-apple, Smol 1.0.34. Timings from /usr/bin/time -p, wall-clock real; Smol’s figures are its own reported job durations. JSON validity was checked with jq -e rather than by eye.
Both fixtures were generated for this article by Python’s csv module and contain no real data. The small one is 414 bytes across four rows and eleven columns, written with CRLF line endings, and carries a field with an embedded comma, a field with doubled double quotes, a field with an embedded newline, leading-zero identifiers, the integer 253+1, a twenty-digit integer, and the literal strings 1e3, .5, -0, 0x1F, null and NaN. The large one is 50,000 rows and 2,267,102 bytes of the same shape. Both were deleted afterwards.
Frequently asked questions
How do I convert CSV to JSON on a Mac from the command line?
Use the preinstalled Python: python3 -c 'import csv,json,sys; json.dump(list(csv.DictReader(sys.stdin)), sys.stdout)' < orders.csv > orders.json. It ran in 0.07 seconds on our test file and handles quoted commas, doubled quotes and embedded newlines correctly, because csv.DictReader implements the actual format.
Why are all my JSON values strings after converting from CSV?
Because a CSV file carries no type information, so a correct converter cannot know that 123 is a number and 00123 is a product code. Emitting strings and casting the genuinely numeric columns afterwards is safer than letting a parser guess. A guess turns 01002 into 1002 and breaks the ZIP code.
Why does the jq CSV to JSON one-liner give wrong results?
jq has no CSV reader, so that recipe splits text on commas and newlines. On our fixture it split the field "Ships Tue, then invoices" at the comma, shifting every later column by one, and left a stray carriage return as an extra field because the file used CRLF line endings. Parse with Python or Miller, then reshape with jq.
Should I use NDJSON or a JSON array?
NDJSON unless something downstream demands an array. Our four records were 784 bytes as NDJSON against 1,075 as an indented array, and more importantly each line is independently valid. head -2 of the NDJSON file gave two complete records, while head -3 of the array file was invalid JSON.
Why did a large ID change value in my JSON?
JavaScript stores every JSON number as an IEEE 754 double, which runs out of integer precision above 2^53. In Node v26.7.0, JSON.parse of ext_id 9007199254740993 returns 9007199254740992 — the last digit changes at parse time. Keep identifiers as JSON strings and they stay exact.
Keep reading