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 表頭取欄集合,兩個集合做差。差集非空就是丟了欄位。批次處理場景把這條斷言寫進測試,轉換邏輯改動時會自動攔截。