What JSON Loses in CSV and Type Generation: One Fixture, Eight Tools

2026-11-10

The question

When you convert JSON to CSV and back, or generate types from a JSON sample, which values come out different, and how would you notice?

People often say "CSV loses types" and leave it there. But different converters lose different things, and the details matter: is null still null? Does a missing field come back as an empty string? Does an ID above 253 keep its last digit?

To find out, we wrote one small fixture with the values that usually cause trouble and ran it through eight of this site's tools in a real browser. We pasted the input, downloaded each output and compared it with the original using a script. This article reports what these tools did with that fixture. Other converters, libraries and spreadsheets may behave differently.

The fixture

Five product records. Each field is there to test one kind of value:

FieldWhat it tests
idNormal integers, plus 9007199254740993 in record 3. That is 253 + 1, the first integer a JavaScript number can't store exactly.
skuStrings that look like numbers: "00123", "04500", "000", "123", "00123-B"
nameA comma, double quotes and a newline in one string
price, rate5.0 written with a trailing zero, and 0.1234567890123456789 with 19 decimal places
activeBooleans
discount, notenull in some records, missing in record 3, 0, "", and the string "true"
tagsAn array, an empty array [], null, and missing
dimsA nested object, an object with a null inside, and null
variantsAn array of objects, an empty array, and one object with an extra backorder key
contactOnly in record 1: "+1 555 0100"
[
  {
    "id": 1001,
    "sku": "00123",
    "name": "Widget, \"Pro\"\nedition",
    "price": 19.99,
    "rate": 0.1234567890123456789,
    "active": true,
    "discount": null,
    "note": "",
    "tags": ["new", "sale"],
    "dims": { "w": 10, "h": 2.5 },
    "variants": [{ "color": "red", "stock": 3 }],
    "contact": "+1 555 0100"
  },
  {
    "id": 1002,
    "sku": "04500",
    "name": "Gadget",
    "price": 5.0,
    "rate": 1.5,
    "active": false,
    "discount": 0.15,
    "note": null,
    "tags": [],
    "dims": { "w": 3, "h": null },
    "variants": []
  },
  {
    "id": 9007199254740993,
    "sku": "123",
    "name": "Big-ID part",
    "price": 0,
    "rate": 2,
    "active": true,
    "tags": ["x"],
    "dims": null,
    "variants": [{ "color": "blue", "stock": 0, "backorder": true }]
  },
  {
    "id": 1004,
    "sku": "000",
    "name": "Cable",
    "price": 7.25,
    "rate": 0.5,
    "active": true,
    "discount": null,
    "note": "fragile",
    "tags": null,
    "dims": { "w": 1, "h": 1 },
    "variants": [{ "color": "green", "stock": 12 }, { "color": "black", "stock": 4 }]
  },
  {
    "id": 1005,
    "sku": "00123-B",
    "name": "Spare",
    "price": 3.5,
    "rate": 0.25,
    "active": false,
    "discount": 0,
    "note": "true",
    "dims": { "w": 2, "h": 2 },
    "variants": [{ "color": "red", "stock": 1 }]
  }
]

How we ran it

  • Date: 10 November 2026.
  • Browser: headless Chromium 145.0.7632.6 at 1440×1000, driven by Playwright. The script used each page the way a person would. It pasted text into the editor, set the options, and saved the result with the page's own Download button (or read the output editor if the page had no download).
  • Site: a local development build of the site's staging branch. The converters run entirely in the browser, so the build type shouldn't change their output. We didn't repeat the run on the production site.
  • Tools, in order: JSON to CSV, CSV to JSON, CSV ⇄ JSONL in both directions, JSON to JSONL, JSON to TypeScript, JSON to Zod, JSON to JSON Schema and the JSON Schema Validator. Every option was left at its default.
  • Comparison: a Node.js script parsed the fixture and each output with lossless-json, so numbers were compared as their exact source text, and listed every value whose type or text changed.

Step 1: JSON to CSV

The JSON to CSV Converter with the fixture pasted on the left and the CSV output on the right; record 3's id shows as 9007199254740992

The JSON to CSV Converter produced:

id,sku,name,price,rate,active,discount,note,tags,dims,variants,contact
1001,00123,"Widget, ""Pro""
edition",19.99,0.12345678901234568,true,,,"[""new"",""sale""]","{""w"":10,""h"":2.5}","[{""color"":""red"",""stock"":3}]",'+1 555 0100
1002,04500,Gadget,5,1.5,false,0.15,,[],"{""w"":3,""h"":null}",[],
9007199254740992,123,Big-ID part,0,2,true,,,"[""x""]",,"[{""color"":""blue"",""stock"":0,""backorder"":true}]",
1004,000,Cable,7.25,0.5,true,,fragile,,"{""w"":1,""h"":1}","[{""color"":""green"",""stock"":12},{""color"":""black"",""stock"":4}]",
1005,00123-B,Spare,3.5,0.25,false,0,true,,"{""w"":2,""h"":2}","[{""color"":""red"",""stock"":1}]",

What changed before the data even reached CSV:

  • The big ID changed. 9007199254740993 became 9007199254740992. The cell is wrong, and nothing on the page says so.
  • The long decimal was rounded. 0.1234567890123456789 became 0.12345678901234568.
  • 5.0 became 5. The value is the same; only the text changed.

These three come from the page reading the input with JavaScript's JSON.parse, which turns every number into a 64-bit float. Step 4 shows that the CSV format itself isn't the cause.

What the CSV can't represent:

  • null, a missing key and "" all become an empty cell. Look at discount: null in record 1, missing in record 3, and both cells are empty. Same for note: "" in record 1, null in record 2.
  • Nested values are stored as JSON text inside a cell. dims and variants are still readable, and the null inside record 2's dims survives as text, but a CSV reader sees one string per cell.
  • An empty array is not lost. [] is written as [], so it stays different from null (empty cell).

What survived:

  • The name with a comma, quotes and newline was quoted correctly.
  • "00123", "04500" and "000" were written with their leading zeros.
  • Booleans became true and false.

One change is deliberate: +1 555 0100 became '+1 555 0100. The converter adds a ' before any text that starts with =, +, - or @, so a spreadsheet won't run it as a formula. That protects against CSV formula injection, but the ' is now part of the data.

Step 2: CSV back to JSON

We pasted that CSV into CSV to JSON. Every value came back as a string:

{
  "id": "9007199254740992",
  "sku": "123",
  "price": "0",
  "active": "true",
  "discount": "",
  "note": "",
  "tags": "[\"x\"]",
  "dims": "",
  "variants": "[{\"color\":\"blue\",\"stock\":0,\"backorder\":true}]",
  "contact": ""
}

That's record 3, with name and rate left out. Compared with the original fixture, 47 of the values had changed:

  • Numbers and booleans became strings: 1001 → "1001", true → "true".
  • null and missing keys both became "". Record 3 now has discount, note and contact keys it never had.
  • Arrays and objects became strings of JSON.
  • The changes from step 1 (the ID, the rate, the ' prefix) are still there.

The leading zeros, "123" and "true" are fine here, because nothing was converted.

Step 3: CSV to JSONL with type inference

The CSV ⇄ JSONL converter has an Infer numbers, booleans & null option, which is on by default. We pasted the same CSV.

The CSV to JSONL Converter with the infer types option checked; record 3's output shows "id":"9007199254740992" as a string and "sku":123 as a number

Inference fixed most of step 2. Numbers and booleans were numbers and booleans again, and the leading-zero SKUs stayed strings. 30 values were still different from the original:

{"id":"9007199254740992","sku":123,"name":"Big-ID part","price":0,"rate":2,"active":true,"discount":"","note":"","tags":"[\"x\"]","dims":"","variants":"[{\"color\":\"blue\",\"stock\":0,\"backorder\":true}]","contact":""}
{"id":1005,"sku":"00123-B","name":"Spare","price":3.5,"rate":0.25,"active":false,"discount":0,"note":true,"tags":"","dims":"{\"w\":2,\"h\":2}","variants":"[{\"color\":\"red\",\"stock\":1}]","contact":""}
  • The big ID became a string. The tool won't turn 9007199254740992 into a number because it's above the safe integer limit. That stops further damage, but the ID's type is now different from the other records, and its digits were already wrong in step 1.
  • Two strings changed type. "sku": "123" became the number 123, and "note": "true" became the boolean true. "00123", "04500" and "000" stayed strings because of the leading zero. So the sku column now holds a mix of strings and numbers.
  • Empty cells became "", not null. The nulls in discount, note, tags and dims, and all the missing keys, came back as "". Only a cell containing the text null is turned into null; an empty cell isn't.
  • Nested values stayed strings. Inference doesn't parse the JSON inside cells.
  • The rounded rate, 5 instead of 5.0 and the '+1 555 0100 prefix carried through.

Value by value

Original valueCSV cell from JSON to CSVBack via CSV to JSONBack via CSV to JSONL, inference on
"id": 90071992547409939007199254740992"9007199254740992""9007199254740992" (string)
"rate": 0.12345678901234567890.12345678901234568"0.12345678901234568"0.12345678901234568
"price": 5.05"5"5
"discount": nullempty""""
discount missing (record 3)empty""""
"note": ""empty"" ✓"" ✓
"tags": [][]"[]""[]"
"tags": null / missingempty""""
"dims": {"w": 3, "h": null}{"w":3,"h":null} as textstringstring
"dims": nullempty""""
"variants": [{…}, {…}]JSON textstringstring
"sku": "00123"00123"00123" ✓"00123" ✓
"sku": "123"123"123" ✓123 (number)
"note": "true"true"true" ✓true (boolean)
"active": truetrue"true"true ✓
"name": "Widget, \"Pro\"\nedition"quoted correctly✓✓
"contact": "+1 555 0100"'+1 555 0100"'+1 555 0100""'+1 555 0100"
contact missing (records 2–5)empty""""

✓ means the value came back exactly as it was in the fixture.

Step 4: A path that keeps every digit

JSON to JSONL and the JSONL → CSV side of CSV ⇄ JSONL parse numbers with lossless-json instead of JSON.parse. We ran the fixture through JSON → JSONL and the round-trip diff found 0 changes: 9007199254740993, 0.1234567890123456789 and 5.0 were all kept as written.

Then we turned that JSONL into CSV:

The JSONL to CSV direction of the CSV JSONL converter; the CSV output keeps 0.1234567890123456789, 5.0 and 9007199254740993

Compared cell by cell with the CSV from step 1, exactly 3 cells were different:

CellJSON to CSVJSON → JSONL → CSV
Record 1 rate0.123456789012345680.1234567890123456789
Record 2 price55.0
Record 3 id90071992547409929007199254740993

Everything else was the same, including the empty cells, the JSON-in-cell nesting and the ' prefix. That separates two kinds of loss:

  • Number precision was lost by the parser, not by CSV. A tool that reads numbers losslessly can write the exact digits into CSV.
  • null versus missing versus "", real nesting, and types are lost by CSV itself. No CSV writer can keep them without adding its own convention, such as writing null as text.

If you must go through CSV with large IDs, JSONL → CSV keeps the digits. Reading the CSV back with inference still turns the ID into a string, which is safer than a wrong number.

Type generation: what the generators inferred

Type generators don't change your data, but they decide what the data is allowed to be. From the same fixture:

JSON to TypeScript:

export type Root = RootObjectItem[];

export interface Dims {
  w: number;
  h: number | null;
}

export interface Variant {
  color: string;
  stock: number;
  backorder?: boolean;
}

export interface RootObjectItem {
  id: number;
  sku: string;
  name: string;
  price: number;
  rate: number;
  active: boolean;
  discount?: number | null;
  note?: string | null;
  tags?: string[] | null;
  dims: Dims | null;
  variants: Variant[];
  contact?: string;
}

JSON to Zod produced the same shape: discount: z.number().nullable().optional(), dims: DimsSchema.nullable(), contact: z.string().optional(), and id: z.number().

JSON to JSON Schema produced a different answer for several fields:

FieldWhat the fixture containsTypeScript / ZodJSON Schema generator
idintegers, one above 253number"type": "number"
discountnull twice, missing onceoptional and nullable["null", "number"], required
notenull once, missing onceoptional and nullable["string", "null"], required
tagsnull once, missing once, [] onceoptional and nullable["array", "null"], required
dimsnull in record 3Dims | null"type": "object" (null not allowed), required
dims.hnull oncenumber | null["number", "null"]
variants[].backorderin one variant onlyoptionalnot required
contactin record 1 onlyoptionalnot required

TypeScript and Zod kept optional (the key can be missing) and nullable (the key can be null) apart, and got both right for every field. The JSON Schema generator marked three fields as required even though record 3 or 5 doesn't have them, and didn't allow null for dims.

The generated schema also declares JSON Schema draft-04 in its first line:

"$schema": "http://json-schema.org/draft-04/schema#"

The site's JSON Schema Validator supports draft-07, 2019-09 and 2020-12, so it refused to compile the schema as-is:

no schema with key or ref "http://json-schema.org/draft-04/schema#"

After we deleted that one line, the validator checked the original fixture against the generated schema and reported 4 violations: missing discount, note and tags, and dims must be an object. The schema rejects the sample it was generated from, so review generated required lists and nullable types before you use them.

None of the three generators marked id as an integer or noticed that one ID was above 253. They can't: by the time they look at the data, JSON.parse has already turned it into 9007199254740992.

How to detect these losses

1. Diff the round trip, comparing numbers as text

Convert, convert back, and compare every value with the original by type and exact text. Read both sides with a lossless parser, otherwise the diff has the same blind spot as the converter. This is a shortened version of the script we used (Node.js with lossless-json):

import { parse, isLosslessNumber } from 'lossless-json';

// Describe a value by its JSON type and exact source text.
const describe = (v) =>
  v === undefined ? '(missing)'
  : isLosslessNumber(v) ? `number ${v}`
  : v === null ? 'null'
  : `${Array.isArray(v) ? 'array' : typeof v} ${JSON.stringify(v, (k, x) => (isLosslessNumber(x) ? `#${x}` : x))}`;

function roundTripDiff(beforeText, afterRecords) {
  const before = parse(beforeText);
  const changes = [];
  before.forEach((rec, i) => {
    const after = afterRecords[i] ?? {};
    for (const key of new Set([...Object.keys(rec), ...Object.keys(after)])) {
      const [a, b] = [describe(rec[key]), describe(after[key])];
      if (a !== b) changes.push(`record ${i + 1} ${key}: ${a} -> ${b}`);
    }
  });
  return changes;
}

Run on the step 3 output, it reported 30 changes, including:

record 1 rate: number 0.1234567890123456789 -> number 0.12345678901234568
record 1 discount: null -> string ""
record 2 tags: array [] -> string "[]"
record 3 id: number 9007199254740993 -> string "9007199254740992"
record 3 sku: string "123" -> number 123
record 3 discount: (missing) -> string ""
record 5 note: string "true" -> boolean true

This catches everything in the table above, but only if you still have the original to compare against.

2. Check numbers when you parse

Newer JavaScript engines pass a third context argument to the JSON.parse reviver, with the number's original text in context.source. Comparing it with the parsed value finds numbers that didn't survive:

function findLossyNumbers(text) {
  const findings = [];
  JSON.parse(text, function (key, value, context) {
    if (typeof value === 'number' && context?.source !== undefined && String(value) !== context.source) {
      findings.push({ key, source: context.source, parsed: value, safeInteger: Number.isSafeInteger(value) });
    }
    return value;
  });
  return findings;
}

On the fixture, in Chromium 145 and Node.js 24, it found three numbers:

KeySource textParsedNumber.isSafeInteger
rate0.12345678901234567890.12345678901234568false
price5.05true
id90071992547409939007199254740992false

price is only a formatting difference. The other two lost information. For integer fields such as IDs, Number.isSafeInteger(value) === false on its own is a useful alarm.

We only tested this in Chromium and Node.js. In an engine without context.source, the function silently finds nothing, so test for support first:

const hasSourceText = JSON.parse('1', (k, v, ctx) => ctx?.source) === '1';

If it isn't supported, parse with a lossless parser instead.

3. Validate against a schema you wrote

A schema written from what the data means, not generated from one sample, catches the type changes. Ours says id is an integer with "maximum": 9007199254740991, lists only the fields that are always present in required, and allows null where the data uses it.

In the JSON Schema Validator:

  • Original fixture: 1 violation, /2/id must be <= 9007199254740991. The validator also parses with JSON.parse, so it saw 9007199254740992. Over the limit either way, so the maximum still caught it.
  • After CSV to JSON: 40 violations, all of them type errors.
  • After CSV to JSONL with inference: 21 violations, including /2/id must be integer, /2/sku must be string, /4/note must be string or null, and the "" values in discount, tags and dims.

The JSON Schema Validator showing INVALID, 21 rule violations, with rows such as /0/discount must be number,null, /2/id must be integer and /2/sku must be string

What the schema didn't catch:

  • rate rounded to 17 significant digits. It's still a valid number.
  • '+1 555 0100. It's still a string.
  • note changing from null to "". The schema allows both.
  • Records 2–5 gaining "contact": "". Also still a string.

A schema checks shape and type. It can't tell you a value changed if the new value is still allowed.

4. Tighten generated Zod schemas

We ran the generated Zod schema in Node.js 24 with Zod 4.5.4. It accepted the original fixture, holding the wrong ID 9007199254740992. It rejected the CSV to JSON output with 40 issues and the inferred output with 21. Changing one line made it catch the ID too:

id: z.int().positive(),

In this Zod version, z.int() only accepts safe integers, so the original fixture then failed:

2.id: Too big: expected int to be <=9007199254740991

A Zod check runs after JSON.parse, so like the schema, it can catch an ID that is over the limit but can't recover the right digits.

Limitations

  • These results describe this site's converters on 10 November 2026, with their default options. Other converters may keep null, parse nested JSON in cells, read numbers losslessly, or skip the ' formula prefix. Test the tools you use with your own fixture.
  • One browser (Chromium 145), on a local development build. We didn't repeat the run in Firefox or Safari.
  • We didn't open the CSV in Excel, Google Sheets or LibreOffice. What spreadsheets do with leading zeros, long numbers and the ' prefix isn't covered here.
  • Five records is enough to show each behaviour, not to measure how often it happens in real data.

Try it yourself

  1. Open the fixture in JSON to CSV and look at record 3's id.
  2. Paste the CSV into CSV to JSON and CSV ⇄ JSONL, and compare the outputs with the table above.
  3. Open the contract schema and fixture in the JSON Schema Validator to see the /2/id violation.

Related guides: