JSON 转 CSV:表头从哪来,值为什么会变味
结论:用站点真实转换器跑九组实测:第一行键策略会静默丢字段,null、缺失与空串三态全部变空格,20 位整数在 JSON.parse 阶段就已丢精度,键含点号会把两条不同路径撞成同一列。本文讲清表头怎么生成、值怎么变味、按下游用途怎么选策略。

CSV 转换丢数据,十有八九不是丢在「转」这个动作上,而是丢在两个没人问过的问题里:表头是谁定的,以及每个格子里的值还认不认得出原样。
一、先复现一个 30 秒的丢字段现场
市面上不少转换器(包括 jq 的惯用写法)都从第一条记录取表头。用 Python 快速复现这条路径的后果:
data = [{"id": 1, "name": "A"}, {"id": 2, "email": "b@x.com"}]
first_keys = list(data[0].keys()) # ['id', 'name']
rows = [[r.get(k, "") for k in first_keys] for r in data]
# 第二条记录的 email 没有报错、没有警告,直接消失了
第二条记录的 email 不在表头里,于是它连「变成空单元格」的资格都没有——列都没建,值被静默丢掉。修复方式是键并集:扫全部记录,把出现过的键都建成列。同一份数据走键并集,表头是 id,name,email,第二行的 name 补空格,字段一个不丢。
本站 /convert/csv 的正向转换用的就是键并集:实测输入 [{"id":1,"name":"A"},{"id":2,"email":"b@x.com"}],输出:
id,name,email
1,A,
2,,b@x.com
键按首次出现的顺序排列,缺失字段补空单元格。这也是 jsonoperations 与 jsonbeam 两家竞品教程共同强调的「经典 bug 修复法」——但只说「用并集」还不够,并集有自己的代价,见下一节。
二、表头的三种来源,各自适合谁
| 表头策略 | 怎么生成 | 优点 | 代价 |
|---|---|---|---|
| 第一行键 | 只扫第一条记录 | 列少、稳定、jq 一行搞定 | 后续记录的新字段静默丢失 |
| 键并集 | 扫全部记录 | 字段不丢 | 记录形状差异大时列稀疏、大量空格 |
| 显式列清单 | 人工或 schema 指定 | 完全可控、可复现 | 要维护,新字段要手动加 |
选型的判断标准不是「哪个高级」,而是下游拿这份 CSV 做什么:
- 给人临时看一眼:第一行键都行,反正记录形状通常一致;
- 进报表或数据管道:键并集是底线,空单元格本身就是信息;
- 定期跑的自动化导出:显式列清单。形状漂移(上游加了字段、改了键名)会直接报错,而不是悄悄产出一份没人发现异常的表格。
第三种最常被忽略。自动化场景里,「转换成功」和「转换正确」是两回事,显式列清单是把这两个概念拆开的唯一机制。
三、键名里带点号:扁平化路径会撞车
点号扁平化把 {"a": {"b": 2}} 变成列 a.b。但如果键名本身含点号呢?用 pandas 实测:
import pandas as pd
d = [{"a.b": 1, "a": {"b": 2}}]
pd.json_normalize(d, sep=".")
# 列只剩 ['a.b'] —— 两条完全不同的键撞成同一条路径
a.b 这个原始键与嵌套路径 a → b 生成同名列,一个覆盖另一个,数据静默损坏。这是个构造出来的例子,但真实数据里完全可能发生:监控系统的 metric 名(cpu.usage)、日志字段(error.type)都爱用点号。
两种解法:
- 换分隔符:
sep="_"或自定义分隔符,先确认原键名不含这个字符; - 换路径记法:JSON Pointer 用
/作分隔符(/a/b),toolvore 的 Excel 转换器就走这条路。它同样有歧义(键含/时 Pointer 要求写成~1),但至少规则是标准化的,RFC 6901 白纸黑字。
无论选哪种,规则必须写进导出文档。CSV 的表头是这段数据唯一的「schema」,读者没有别的地方可以还原你的展平规则。
四、数组怎么办:三种处置各有各的丢法
数组是 JSON → CSV 真正的坎:一个格子装不下多个值。三种处置策略,实测各自的有损点:
| 策略 | 输出形态 | 丢什么 | 适合 |
|---|---|---|---|
| JSON-in-cell | 数组序列化成 JSON 字符串塞进格子 | 表格工具没法筛选、聚合里面的元素 | 保真优先,人工备查 |
| 索引成列 | tags.0、tags.1 各一列 |
列数随最长数组爆炸;空位多 | 长度固定的小数组 |
| 炸成多行 | 每个元素一行,父字段复制 | 行数膨胀;空数组要不要留行要单独定义 | 明细分析、按元素聚合 |
本站 /convert/csv 与 /convert/table 采用 JSON-in-cell。实测输入 [{"orderId":"A001","items":[{"sku":"X1","qty":2},{"sku":"Y9","qty":1}]}],输出:
orderId,items
A001,"[{""sku"":""X1"",""qty"":2},{""sku"":""Y9"",""qty"":1}]"
需要炸行时用 pandas 的 record_path:
orders = [{"orderId": "A001", "total": 99,
"items": [{"sku": "X1", "qty": 2}, {"sku": "Y9", "qty": 1}]}]
items = pd.json_normalize(orders, record_path=["items"],
meta=["orderId", "total"], sep=".")
# 两行明细,orderId 与 total 复制到每行
炸行有三个坑要提前定规则:多个数组同时炸会制造笛卡尔积;空数组默认不留行(客户列表会少人);父字段重复后,下游按 orderId 聚合要去重。convertcsvonline 的指南把「先定粒度(grain)再转」列为第一步,就是这个原因——粒度定了,这批问题才有唯一答案。
五、实测:这些值过一遍转换就变了味
用本站真实转换器(Node v22.13.1 + papaparse,与 /convert/csv 同一实现)跑的一组保真实验:
1. null、缺失、空串三态全部变空格。 输入 [{"a":null},{"b":""},{"c":1}],三行输出里 a、b、c 全是空单元格。CSV 没有三态,只有「有字符」和「没字符」。往返还原后三者都是 "",原始语义不可区分。
2. 嵌套对象与数组序列化成 JSON 字符串。 引号按 RFC 4180 规则翻倍({""name"":""张三""}),粘回任何 JSON 解析器都能还原——这是 JSON-in-cell 策略保真的原因。
3. 20 位整数在解析阶段就丢精度,锅不在 CSV。 输入 "id": 9007199254740993,CSV 里已经是 9007199254740992。JavaScript 的 JSON.parse 用 IEEE 754 双精度,解析那一步损失就发生了,任何 CSV 转换器都救不回来。Python 的 json.load 则完整保留。标识符类字段(雪花 ID、订单号)让上游直接发字符串。数字精度问题见大数字为什么会丢精度。
4. 字符串前导零在 CSV 文本层是安全的。 "phone": "00123" 进 CSV 还是 00123,因为它从头到尾是字符串,没有谁会去剥它。会被剥前导零的是「Excel 把它当数字」的那一步,见JSON 转 Excel 的四种办法。
5. 含逗号、引号、换行的字段。 papaparse 按 RFC 4180 处理:字段加引号、内部引号翻倍。实测 "He said \"hi\", ok" 输出为 "He said ""hi"", ok",往返无损。自己拼字符串绕过库的,几乎都栽在这一条。
六、反向还原:推断开关开与不开差在哪
CSV → JSON 每个格子都要猜类型。本站转换器默认关闭类型推断,全部还原为字符串:
[{ "id": "9007199254740992", "phone": "00123" }]
打开 typeInference 后,id 变成数字(精度已经回不来),而 phone 保持字符串——因为推断规则排除了前导零写法(010 这类不是合法数字字面量),字符串侥幸过关。
这条不对称值得记住:关推断,你最多损失类型;开推断,你可能在损失数据。需要数字的列,用显式列定义转,别信全局推断。旧文JSON 与 CSV、表格数据的互转里的 rowsToRecords 按列定义转型的写法,就是这个原则的落地实现。
七、按下游用途选策略:一张决策表
| 你要把 CSV 交给谁 | 表头策略 | 数组处置 | 类型处理 |
|---|---|---|---|
| 同事临时打开看 | 键并集 | JSON-in-cell | 不推断 |
| Excel / 报表 | 键并集 | JSON-in-cell 或索引列(长度固定时) | 标识符列强制文本 |
| 数据库导入 | 显式列清单 | 拆主表 + 关联表,或炸行 | 显式列定义 |
| BI / 聚合分析 | 显式列清单 | 炸行,粒度 = 分析最小单元 | 显式列定义 |
| 自动化管道 | 显式列清单 | JSON-in-cell,炸行放下游 | 关推断,逐列转 |
共同规律:离人越近,越可以宽松;离机器越近,越要显式。
八、可复制的自检清单
- [ ] 表头来自全记录键并集,还是显式列清单?如果不是,能否证明没有记录被丢字段?
- [ ] 键名是否可能含点号/斜杠?扁平化分隔符与原键名的碰撞检查过吗?
- [ ] 数组的处置策略写进文档了吗?空数组留不留行有结论吗?
- [ ]
null、缺失、空串三种输入,下游各读成什么?三选一并写死; - [ ] 标识符列(ID、单号、手机号)是否全程字符串承载?
- [ ] 炸行后行数 = 各数组长度之和,抽查对过吗?
- [ ] 含逗号、引号、换行的样本字段,往返一圈值不变吗?
- [ ] 反向还原关掉了全局类型推断,需要数字的列有显式定义吗?
这八条全部有明确答案,这份 CSV 才算「转换正确」,而不只是「转换成功」。
常见问题
为什么转换器不默认把嵌套对象展平成多列?
展平列数不可控:深度嵌套会生成 user.address.geo.lat 这类长列名,且与「键并集」策略叠加后,一条形状不同的记录就能加出一批列。塞 JSON 字符串保真且列数稳定,需要扁平化时用显式规则自己展。
表头出现重复列名怎么办?
两条不同键展平后撞名(比如 a.b 与嵌套 a→b)必须在上游解决:换分隔符或换 JSON Pointer 记法。转换器对同对象内的重复键通常取后值,表头层面的重名则要看实现—— papaparse 的 unparse 不会去重,导出的 CSV 双击打开时 Excel 会静默合并或丢弃,这也是要在自检清单里查的一项。
数组炸行后,父级字段重复是 bug 吗?
不是,是设计。炸行的语义就是「明细行」,明细行必须能通过父键关联回主记录。下游聚合时按父键分组即可;如果担心体积,先炸成明细文件、再单独导一份主表。
怎么验证我的转换没有丢字段?
做一次键审计:对原始 JSON 取全键并集,再对 CSV 表头取列集合,两个集合做差。差集非空就是丢了字段。批处理场景把这条断言写进测试,转换逻辑改动时会自动拦截。