Convert between CSV and JSON without silently losing structure.
Single file, standard library only, no dependencies. Tested in CI on Python 3.9 through 3.13.
Most CSV/JSON converters break on the same things: nested objects get
stringified or dropped, ragged records lose columns, 007 becomes 7, and a
field that is absent comes back as null. This one is built so that
json2csv followed by csv2json --infer --nest gives you back what you
started with.
curl -O https://raw.githubusercontent.com/clawedassistant26/tabular-convert/main/tabular_convert.py
chmod +x tabular_convert.py# CSV -> JSON
./tabular_convert.py csv2json input.csv -o out.json --infer --nest
# JSON -> CSV
./tabular_convert.py json2csv input.json -o out.csv
# Use - for stdin/stdout, so it pipes
cat in.csv | ./tabular_convert.py csv2json - -o - --infer| Flag | Effect |
|---|---|
--infer |
Turn numeric, boolean and null-looking cells into real JSON types |
--nest |
Expand dotted column names (user.name) into nested objects |
--string-columns a,b |
Exempt these columns from --infer |
--delimiter ';' |
Input delimiter, default , |
--indent N |
Output indent, 0 for compact |
| Flag | Effect |
|---|---|
--delimiter ';' |
Output delimiter, default , |
Nested objects and arrays flatten to dotted keys and rebuild exactly:
[{"user": {"name": "ann", "roles": ["admin", "dev"]}}]user.name,user.roles.0,user.roles.1
ann,admin,devRagged records share a union header, in first-seen column order, so no record loses a field.
Absent vs null are kept distinct. An empty cell means the record has no
such field. An explicit JSON null is written as the literal null. This is
the difference between {"a": 1} and {"a": 1, "b": null}, and both survive
a round trip.
Empty containers survive. {} and [] have no leaf values to write, so
they are encoded as those literals rather than vanishing.
Booleans and nulls use JSON spelling (true, false, null), not
Python's True/False/None, so other tools can read the CSV.
Conservative type inference. --infer deliberately leaves anything
ambiguous as a string: leading zeros (007), a leading +, underscores
(1_000), surrounding whitespace, and nan/inf (which are not valid
JSON). Zip codes and phone numbers stay intact.
Sparse list indices stay objects. Columns c.0 and c.2 with no c.1
become {"0": ..., "2": ...}, not a list with an invented middle element.
Real CSV quoting via the standard library: embedded commas, quotes, newlines and unicode all work.
These are properties of CSV, not bugs, and it is better to know them up front.
--infercannot recover the difference between the string"90210"and the number90210. CSV carries no type information, so a numeric-looking string comes back as a number. Use--string-columns zip,phoneto protect specific columns, or omit--inferto keep everything as strings.- Likewise for the literal strings
"true","false"and"null", which--inferreads as the corresponding JSON values. - A JSON key containing a literal dot collides with the nesting separator
under
--nest. Without--nestit is passed through untouched. json2csvholds the parsed input in memory, because the whole array must be seen before the union header is known.csv2jsonreads lazily but buffers the record list to emit one valid JSON array. Both handle 200k rows / ~13MB comfortably; for much larger data use JSON Lines instead.- Top-level JSON must be an array of objects. Anything else is a clear error, not a guess.
Bad input exits 2 with a one-line message and no traceback. Detected cases
include: duplicate header names, empty header names, a row with more fields
than the header, invalid JSON, a non-array top level, a non-object array
element, a --string-columns name that is not in the header, and a column
that structurally conflicts with its own nested columns.
python3 -m pytest tests/ -q64 tests covering type inference, flatten/nest round trips, CSV quoting edge cases, ragged records, absent-vs-null, error paths, and the CLI end to end via subprocess.
MIT