JSON 工具箱

JSON 轉 CSV:表頭從哪來,值為什麼會變味

結論:用站點真實轉換器跑九組實測:第一行鍵策略會靜默丟欄位,null、缺失與空字串三態全部變空格,20 位整數在 JSON.parse 階段就已丟精度,鍵含點號會把兩條不同路徑撞成同一列。本文講清表頭怎麼生成、值怎麼變味、按下游用途怎麼選策略。

進階約 6 分鐘發佈於 2026-11-04更新於 2026-11-04
JSON 轉 CSV 的表頭與欄位保真實測封面

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)都愛用點號。

兩種解法:

  1. 換分隔符:sep="_" 或自訂分隔符,先確認原鍵名不含這個字元;
  2. 換路徑記法: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 表頭取欄集合,兩個集合做差。差集非空就是丟了欄位。批次處理場景把這條斷言寫進測試,轉換邏輯改動時會自動攔截。