嵌套 JSON 扁平化(flatten)为 CSV:5 种策略与决策矩阵
几何问题
每次都会撞上同一堵墙。API 返回嵌套 JSON,Slack 上的分析师只想要一份电子表格;mongoexport 吐出带 $oid 包装和三层元数据的文档,而 BigQuery 期望一张扁平表。把嵌套 JSON 扁平化为 CSV不是语法问题,而是几何问题。JSON 是一棵树,CSV 是一张网格,树要进网格,就必须先决定枝条如何坍缩。
可选的坍缩策略一共只有五种。选错一种,你可能给 Excel 推送 200 列数据、丢失 Twitter ID 的精度,或者破坏掉整条管道依赖的往返一致性。选对了,整次转换就是一行代码的事。
| 策略 | 一句话示例 | 适用场景 |
|---|---|---|
| 点号 | customer.address.city | Excel/Sheets 分析 |
| 下划线 | customer_address_city | SQL 友好列名 |
| 索引数组 | items.0.sku, items.1.sku | 定长数组 |
| 行展开 | 每个子元素复制一遍父字段 | Pandas/BigQuery 分析 |
| Stringify | 一个 cell 里塞 "{\"city\":\"Seattle\"}" | 无损往返 |
下面分别看每种策略,配一张按消费者(Excel、Pandas、BigQuery、Postgres)反查的决策矩阵,再用四份真实负载演示「直觉里的正确答案」往往不是合适的那一个。如果你还需要更宏观的双向转换概览(解析库、流式处理、编码陷阱),可以参考 CSV 与 JSON 互转:方法、陷阱与代码示例。
为什么嵌套 JSON 装不进 CSV
JSON 携带了三类 CSV 缺失的结构。层级是对象套对象;序列是数组;混合是两者的组合:对象数组、带数组的对象、数组的数组。一份典型的电商订单往往三者同时出现。
CSV 只有两个维度:行和列。没有第三根坐标轴来承载「这一列底下还挂着三个子节点」。当你要求一棵树进网格时,总要牺牲点什么。要么把子节点摊开成更多列(接受 items.0.options.0.value 这种列名),要么摊开成更多行(父字段重复),要么干脆把它们当字符串塞进单元格,不再当作结构。
下面 5 种策略各自给出了不同的回答。有的保住了可读性,丢掉了往返安全;有的反过来。没有哪种万能,关键看下一个读文件的人是谁。
5 种扁平化策略对比
策略 1:点号(customer.address.city)
点号从根走到叶,用 . 拼接 key。每个嵌套对象的叶子节点变成一列,路径直接编码在列名里。
{ "customer": { "address": { "city": "Seattle" } }, "email": "alice@example.com" }
变成
customer.address.city,email
Seattle,alice@example.com
Pandas 里一行就能搞定:
import pandas as pd
data = [{"customer": {"address": {"city": "Seattle"}}, "email": "alice@example.com"}]
df = pd.json_normalize(data, sep='.')
df.to_csv("out.csv", index=False)
JavaScript 里一个小递归函数就够:
function flattenDot(obj, prefix = '', acc = {}) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
flattenDot(v, key, acc);
} else {
acc[key] = v;
}
}
return acc;
}
优点:人类可读、是 Pandas 的默认行为、保留原始路径。缺点:列名容易拉得很长(Kubernetes spec 会产生形如 spec.template.spec.containers.0.resources.limits.memory 的列名),并且当真实 key 里本身就含 . 时(Google Analytics 4 的事件参数就是这样),点号会产生歧义。
策略 2:下划线(customer_address_city)
思路一样,只是把分隔符换掉。把 . 替换为 _,结果就是 SQL 安全的:SELECT customer_address_city FROM events 不需要给标识符加引号。BigQuery、Snowflake、Postgres 都偏好这种写法。
import pandas as pd
df = pd.json_normalize(data, sep='_')
点号和下划线之间的取舍,纯粹取决于下游工具。Excel 分析师读点号更自然;SQL 引擎对下划线无怨无尤。换一种只要改一个参数。
优点:列名 SQL 安全、符合 BigQuery 标识符规则、无需引号。缺点:当 key 中含 _ 时仍可能歧义(比 . 少见,但并非不会发生)。
策略 3:索引数组(items.0.sku、items.1.sku)
对象扁平化得很干净,因为 key 是唯一的;数组就不一样,长度不固定。索引策略把数组下标当作路径段:items[0] 变成 items.0。
{ "id": "ord-001", "items": [{"sku": "A"}, {"sku": "B"}] }
变成
id,items.0.sku,items.1.sku
ord-001,A,B
这也是我们的 JSON 转 CSV 在线工具 在 Flatten 模式下的默认行为。每个叶子都占一列,位置信息编码在列名里。
优点:每个值都有自己的单元格、保留位置、不会出现行复制。缺点:列数爆炸(100 个 item 就是 100 列);当不同行的数组长度不同时会产生残缺的表格;下游聚合也做不动(无法 SUM(items.*.qty))。
策略 4:行展开(数组转多行)
不再用「加宽表格」去装下数组,改用「加长表格」。把父字段对每个数组元素重复一次,每个元素自己占一行。
{ "order_id": "ord-001", "customer": "Alice", "items": [{"sku": "A", "qty": 2}, {"sku": "B", "qty": 1}] }
变成
order_id,customer,items.sku,items.qty
ord-001,Alice,A,2
ord-001,Alice,B,1
Pandas 里一行同时完成展开和规范化:
import pandas as pd
orders = [{"order_id": "ord-001", "customer": "Alice",
"items": [{"sku": "A", "qty": 2}, {"sku": "B", "qty": 1}]}]
df = pd.json_normalize(orders, record_path='items', meta=['order_id', 'customer'])
df.to_csv("orders.csv", index=False)
SQL 里 UNNEST 做的是同一件事:
SELECT order_id, item.sku, item.qty FROM orders, UNNEST(items) AS item;
优点:Pandas 和 BigQuery 都原生支持这种形状、聚合自然(GROUP BY order_id)、schema 保持窄。缺点:父字段在每行子记录里都要复制(存储膨胀)、1 对 N 的边界是隐式的(你得有一个 order_id),同层级出现两个数组时如果不仔细分别 UNNEST 就会产生笛卡尔积。
策略 5:Stringify(JSON 塞进 cell)
激进选项:根本不扁平。把整个嵌套值序列化成 JSON 字符串塞进一个单元格。外层表保持扁平,内部结构原封不动地保留下来。
{ "id": "ord-001", "items": [{"sku": "A"}, {"sku": "B"}] }
变成
id,items
ord-001,"[{""sku"":""A""},{""sku"":""B""}]"
这就是我们的 JSON 转 CSV 在线工具 里的 Stringify 模式。列数永远不会爆炸,原始形状逐字节保留,反向转换可以精确还原输入。
优点:100% 无损、列数可预期、反向时配合 Infer types 即可往返安全。缺点:Excel 用户会看到被转义的引号、SQL 引擎要查询内部值得调用 JSON 函数(BigQuery 用 JSON_EXTRACT_SCALAR,Postgres 用 ->>'key'),电子表格公式也无法直接探入单元格。
5 种策略并排对比
同一份输入:{"id":"ord-001","customer":{"name":"Alice"},"items":[{"sku":"A","qty":2},{"sku":"B","qty":1}]}。
| 策略 | 列数 | 往返安全 | 最佳消费者 |
|---|---|---|---|
| 点号 | 随数组增长 | 否 | Excel 分析师 |
| 下划线 | 随数组增长 | 否 | SQL 数仓 |
| 索引数组 | 每个数组位 2 列 | 否(反向有歧义) | 定长数组 |
| 行展开 | 列窄,每个子元素 1 行 | 部分(需要 key) | Pandas / BigQuery |
| Stringify | 固定 | 是 | 管道往返 |
决策矩阵:哪种消费者对应哪种策略
先按消费者查一下,再读出推荐的策略。
| 消费者 | 推荐策略 | 理由 |
|---|---|---|
| Excel / Sheets(分析师) | 点号 + 大数组用 Stringify | 列名可读;大数组不会把表格撑爆 |
| Excel-EU(DE/FR/IT/ES) | 点号 + ; 分隔符 + UTF-8 BOM | 必须用分号;BOM 防止编码乱码 |
Pandas(json_normalize + explode) | 下划线 + 行展开 | 列名 SQL 友好;展开后与 groupby 配合自然 |
| BigQuery / Snowflake | TSV + Stringify 或 行展开 | 制表符避开引号陷阱;JSON_EXTRACT 直接查 cell |
PostgreSQL COPY | RFC 4180 + 下划线 + 扁平 | 列名 SQL 安全;严格 RFC 引号 |
| MongoDB → BigQuery ETL | 直接 load NDJSON,跳过 CSV | BigQuery 原生支持 NDJSON,绕开 CSV 这道弯 |
Excel / Google Sheets:locale 陷阱
Excel 的列名在实际使用里基本没有长度上限,真正的陷阱有三个。
第一是 locale 分裂。欧洲版 Excel(德国、法国、意大利、西班牙)期望 ; 作为分隔符,因为 , 是小数点。逗号分隔的 CSV 一打开,每行内容都会被塞进 A 列。我们 JSON 转 CSV 在线工具 的 Excel preset 一键切换到 ; + CRLF + UTF-8 BOM。
第二是科学记数法。Excel 看到 9007199254740993 会渲染成 9.00719925474E+15。把大整数在源 JSON 里就存成字符串,再打开 BOM,让 Excel 把单元格当作文本。我们的转换器会自动检测大整数。
第三是实际可用的列数上限。Excel 理论上支持 16,384 列,但超过 ~500 列就已经难以维护。要么用 Stringify 把笨重的子树打包,要么转换前用 jq 先做一次投影。
Pandas:json_normalize + explode
处理嵌套数组的标准做法是一次 pass 里同时用 record_path 和 meta:
import pandas as pd
orders = [{
"order_id": "ord-001",
"customer": {"name": "Alice", "city": "Seattle"},
"items": [{"sku": "SKU-100", "qty": 2}, {"sku": "SKU-205", "qty": 1}]
}]
df = pd.json_normalize(orders, record_path='items',
meta=['order_id', ['customer', 'name'], ['customer', 'city']], sep='_')
df.to_csv("orders.csv", index=False)
输出是每个 item 一行,order_id、customer_name、customer_city 在每行重复。这比先 explode 再 json_normalize 更好:record_path 跳过了中间那一列对象,而 meta 让你能精确控制哪些父字段会向下传递。当数组元素里又嵌套对象时,可以用 max_level= 限制深度。
BigQuery / Snowflake:TSV + JSON 塞 cell
BigQuery 的 LOAD DATA 对 CSV 引号要求严格,引号文本里再含逗号时经常解析失败。TSV 更安全,因为制表符几乎不会出现在文本字段里:
bq load --source_format=CSV --field_delimiter='\t' \
dataset.orders gs://bucket/orders.tsv \
order_id:STRING,customer:STRING,items:STRING
当嵌套数据以 Stringify 后的 JSON 形式落入单列时,BigQuery 仍能用 JSON_EXTRACT_SCALAR 查询内部值:
SELECT order_id, JSON_EXTRACT_SCALAR(items, '$[0].sku') AS first_sku
FROM dataset.orders;
Snowflake 通过 VARIANT 提供同样的能力,路径写法形如 items:0.sku::STRING。两种引擎里,当嵌套数组很大或长度不定时,Stringify + JSON 路径查询都比完全扁平化要好。
PostgreSQL COPY:严格 RFC 4180
COPY ... FROM ... WITH (FORMAT csv, HEADER true) 是你日常会遇到的最严格的 RFC 4180 读取器。有两个行为最容易踩坑。
第一,COPY 不接受 UTF-8 BOM。字节序标记会被当作第一列名的字面前缀(id 会变成 id),所有引用 id 的查询都会悄无声息地失败。给 Postgres 准备文件时关掉 BOM。
第二,COPY 本身没法解析嵌套数据。要么先把数组展开成多行再 load,要么把目标列定义为 jsonb、嵌套值走 Stringify:
CREATE TABLE orders (order_id text PRIMARY KEY, customer text, items jsonb);
COPY orders FROM '/tmp/orders.csv' WITH (FORMAT csv, HEADER true);
SELECT order_id, items->0->>'sku' AS first_sku FROM orders;
如果整条管道全程都是 JSON,干脆跳过 CSV,用 COPY ... FROM ... WITH (FORMAT text) 配合 JSON 单行输入。
真实负载实战
实战 1:电商订单(customer + items 数组)
一份典型订单同时包含嵌套的客户信息和长度不定的 items 数组:
[{ "id": "ord-001",
"customer": { "name": "Alice", "address": {"city": "Seattle", "country": "US"} },
"items": [{"sku": "SKU-100", "qty": 2}, {"sku": "SKU-205", "qty": 1}] }]
策略选哪种,看下游是谁。财务想要每个 SKU 一行核算收入,那就用行展开,输出两行,id 和 customer.name 重复。运营想要每个订单一行做履约看板,那就点号 + 把 items Stringify 起来,免得数组把列数撑爆。同一份输入,两种输出,对各自的消费者都是对的。
直接动手试:把这段负载粘到我们的 JSON 转 CSV 在线工具 里,在 Nested 选项上来回切 Flatten 和 Stringify。「Nested E-commerce Orders」示例就是同一种形状。
实战 2:GitHub Issues API(labels 数组 + user 对象)
/repos/{owner}/{repo}/issues 接口返回的是一种混合嵌套形状:
[{ "id": 1001, "title": "Bug: login 404", "state": "open",
"labels": ["bug", "priority:high"], "user": {"login": "alice"} }]
user 是只关心一个字段的对象;labels 是长度不定的字符串数组。务实的扁平方案是混合的:user 用点号(你只在意 user.login),labels 内联拼接成单个 cell,元素用 ; 隔开:
id,title,state,labels,user.login
1001,Bug: login 404,open,bug;priority:high,alice
「把数组拼成一个 cell」和「把对象用点号扁平」很难在同一种策略里同时完成。我们的转换器会自动把对象扁平化;labels 那一步要么用 jq 预处理(map(.labels = (.labels | join(";")))),要么接受默认的数组 Stringify 行为。
实战 3:MongoDB mongoexport($oid + metadata)
mongoexport --jsonArray 会输出带 Extended JSON 包装的内容:
[{ "_id": {"$oid": "6634a1b2c3d4e5f600000001"},
"email": "alice@example.com",
"metadata": { "signupDate": "2026-01-15T10:30:00Z",
"preferences": {"newsletter": true, "theme": "dark"} } }]
$oid 包装会产生一个字面名为 _id.$oid 的列,多数 SQL 引擎都不接受。先用 jq 拆掉包装:
mongoexport --collection=users --jsonArray | jq 'map(._id = ._id."$oid")' > users.json
对深嵌套的 metadata.preferences 块,按消费者来选。分析师导出:整段都用点号扁平化,metadata.preferences.theme 读起来很顺。管道往返:把 metadata Stringify 起来,保住结构。完整的 jq 与 CSV 管道搭配模式可以看 jq 速查手册。
实战 4:Kubernetes Pod Spec(深度嵌套)
kubectl get pod -o json 的响应是扁平策略的最差情况。结构动不动就 6 层深(spec.template.spec.containers.0.resources.limits.memory)。无脑点号扁平会产生 70+ 字符的列名、超过 200 列的输出。两种策略可行。
用 kubectl jsonpath 预投影。只挑你真正需要的字段:
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].image}{"\t"}{.status.phase}{"\n"}{end}' > pods.tsv
把 spec Stringify,metadata 扁平化。metadata(name、namespace、labels)保持扁平,把 spec 塞进单个 cell:
kubectl get pods -o json | jq 'map({name: .metadata.name, namespace: .metadata.namespace, spec: (.spec | tostring)})'
然后丢进转换器开 Flatten 模式。spec 列变成一个 JSON cell;metadata 列保持可读。要避开的反模式是直接 kubectl get pod -o json | json-to-csv flatten 而不做任何预投影,列数会失控。
往返安全:扁平化是有损的,Stringify 才是无损的
这里有一条竞品教程通常会跳过的定理。**点号、下划线、索引数组、行展开都是单向投影。**用其中任何一种扁平化之后,仅凭 CSV 就无法精确还原原始 JSON。
反例很容易构造。列名 customer.address.city 在 {"customer": {"address": {"city": "..."}}} 和 {"customer": {"address.city": "..."}} 之间是有歧义的。索引数组看起来可逆,但 CSV 无法判断 items.0.sku 反推出来到底是数组还是带数字键的对象。行展开则要求一个 group-by 键;没有 order_id,就无法判断哪些行原本属于同一个父记录。
只有 Stringify 经得起完整往返。嵌套值以 JSON 字符串原样保留,反向转换器读出单元格、解析、重新插入,原始结构完整无缺。用 Stringify 转换、存成 CSV、粘到我们的 CSV 转 JSON 工具、打开 Infer types,输出的字节会和输入完全一致。
实操准则:管道里要往返,就 Stringify;一次性分析或报表,按消费者从点号、下划线、行展开里选。
在我们的浏览器工具里实操
JSON 转 CSV 在线工具 把 5 种策略里的两种直接做成了选项:Flatten(同时实现点号和索引数组)和 Stringify(在 cell 里保留结构)。其余三种(下划线、行展开、面向 SQL 的 preset)只需要在前面多做一步预处理。
一次典型的会话只要五步:
- 用我们的 JSON 格式化工具 校验输入,避免语法错误悄悄变成转换失败。
- 把 JSON 粘到 JSON 转 CSV 在线工具,转换会即时完成。
- 把 Nested 设为 Flatten 得到点号 + 索引化 key,或者切到 Stringify 让数组和对象保留在单个 cell 里。
- 选一个 preset:管道用 RFC 4180、欧洲版电子表格用 Excel、数仓用 TSV、文本里逗号很多的用 Pipe。
- 点 Swap direction,用 CSV 转 JSON 工具 打开 Infer types 验证 Stringify 是否能完整往返。
所有处理都在你的浏览器里完成。PII、内部导出、生产环境密钥永远不会离开页面;页面加载完之后零网络请求。涉敏数据不允许上传到第三方网站时,这里是安全的选择。
常见陷阱
有六种失败模式反复出现。
- 列名爆炸。 Kubernetes spec 和 GitHub PR 评审线程会产生成百上千条叶子路径。解决:用
jq或kubectl jsonpath预投影,或者把笨重子树 Stringify 起来、只扁平化 metadata。 - 数组长度不一致。 第 1 行有 3 个 item,第 2 行有 5 个。索引数组会让第 1 行的
items.3.sku和items.4.sku留空。解决:改用行展开。 - 反向时索引键被当作字符串。 CSV 转 JSON 看到
items.0.sku时,0严格说是字符串 key。某些反向转换器会重建出{"0": {"sku": "A"}}而不是[{"sku": "A"}]。解决:要往返就用 Stringify。 - key 里本身已经含分隔符。 GA4 事件里有形如
event_params.key的 key,里面就有字面点号;用.扁平化会产生歧义路径。解决:换下划线,或者把这些 key 改名。我们的 从 JSON5 到 JSONC 介绍了对 key 支持更宽松的 JSON 扩展格式。 - 类型在不同层级混用。 有些行的
address是对象,有些是null。扁平化后原本为 null 的位置会留下空白 cell。我们转换器的 schema notes 会给出警告,方便你提醒下游消费者。 - 大整数被 Excel 截断。
$oid里的 Long、Twitter 雪花 ID、K8s 的resourceVersion都会超过 JavaScript 的安全整数范围(2^53 - 1),被悄悄四舍五入;Excel 又会把它们渲染成9.00719925474E+15。解决:源 JSON 里就把 ID 存成字符串,打开 BOM,并用 Excel preset。
FAQ
把嵌套 JSON 扁平化为 CSV 的最佳方式是什么?
把嵌套 JSON 扁平化为 CSV 的最佳方式取决于下游消费者。Excel 或 Google Sheets 用点号;Pandas 或 BigQuery 要做聚合时用行展开;CSV 需要无损往返回 JSON 时用 Stringify。匹配下一个读者的需求。
怎么把 JSON 数组转成多行 CSV?
用行展开策略:父字段按数组元素数量复制一份,每个元素自己占一行。Pandas 里一行 pd.json_normalize(data, record_path='items', meta=['order_id']) 就能搞定;SQL 里 UNNEST(items) 是同一种形状。父键会在每个被展开的行里重复。
CSV 能完整还原回原始的嵌套 JSON 吗?
只有用 Stringify 模式才能完整往返。点号、下划线、索引数组、行展开都是有损的单向投影,反向转换器无法精确重建出原始的树。Stringify 把数组和对象作为 JSON 保留在单个 cell 里,配合 Infer types 时整个往返可以做到字节一致。
为什么 Excel 把我扁平化后的 JSON 显示成一长列?
Excel 把扁平化 JSON 显示成一长列,多半是因为你在欧洲 locale(德国、法国、意大利、西班牙),那边的 , 是小数点保留符,Excel 默认期望分号作分隔符。我们 JSON 转 CSV 在线工具 的 Excel preset 一键切换到 ; + CRLF + UTF-8 BOM。
列名该用点号还是下划线?
目标是 Excel、Google Sheets、Pandas 时用点号,点号是 json_normalize 的默认值,读起来也自然。目标是 SQL 时用下划线:Postgres、BigQuery、Snowflake 对含 . 的标识符要加引号,而下划线在任何引擎里都不需要加引号。
Pandas 的 json_normalize 如何处理对象数组?
Pandas json_normalize 处理对象数组靠 record_path 和 meta 这两个参数。pd.json_normalize(data, record_path='items', meta=['order_id']) 会把 items 展开为每个元素一行,order_id 重复。对没有数组的嵌套对象,更简单的 pd.json_normalize(data, sep='_') 就能产出形如 customer_address_city 的下划线列名。处理很深的树时用 max_level= 限制深度。
扁平化深嵌套 JSON 的列数上限是多少?
扁平化深嵌套 JSON 的列数上限在 Excel 里是 16,384,CSV 本身实质上没有上限,但超过 500 列后输出就难以维护了。Kubernetes Pod spec 或 GraphQL 响应很容易就突破这个数。用 JSON 转 CSV 在线工具 把笨重子树 Stringify 起来,或者用 jq、kubectl jsonpath 先投影。
jq 适合在 CSV 转换前先扁平化 JSON 吗?
是的,jq 就是 CSV 转换前扁平化 JSON 的合适工具。它能一行搞定预投影(map({id, name}))、预展开(.[] | {id, item: .items[]})以及形状规范化。jq 管道在 CSV 步骤之前运行,能精确控制哪些字段会到达转换器。配套模式可看 jq 速查手册。
总结
五条要点:
- JSON 转 CSV 是几何问题,不是语法问题。一棵树要进网格,就必须先选定枝条如何坍缩。
- 5 种策略覆盖了实操里的全部宇宙(点号、下划线、索引数组、行展开、Stringify)。按消费者反查。
- Stringify 是唯一无损的路径。管道往返就用它。
- Excel-EU 和 BigQuery 的 preset 不是随便做的。直接用。
- 真实负载(mongoexport、Kubernetes spec、GitHub 响应)通常要先用
jq或kubectl jsonpath做一次预投影。
把你自己的负载丢进 JSON 转 CSV 在线工具 试试。本地运行,5 种策略都能处理,Stringify 保证无损往返。无需上传,无需注册,数据永不出页。