JSON 塞在字符串列里,DuckDB 三条 SQL 摊开成表
数据库里那种存 JSON 的 VARCHAR 列,几乎是每个做数据的人都绕不开的东西:Postgres 表里的事件 payload、采集任务落库的 API 响应、CSV 导出里的 metadata 列。查的时候只能一个字段一个字段地抠,每抠一次,整份 JSON 就得重新解析一遍。
DuckDB 官方 10 月 16 日发了一篇博客,专门回答这个老问题:这类列不需要先导出成文件再 read_json 读回来,三条 SQL 就地转成强类型的列。我在本地 DuckDB 1.6.0.dev343 上把博客里的每一段代码都跑了一遍,全部成立,还顺带踩到一个博客只提了一句的坑。
先给结论:JSON 列真正的代价是每查一次就重新解析一次;转成类型列之后,这份功只需要做一遍。
● ● ●
老办法:一个字段一个字段抠
DuckDB 的 JSON 抽取操作符 ->> 接受键名或 JSON 路径,返回 VARCHAR,缺的键返回 NULL:
SELECT
id,
payload ->> '$.user.country' AS country,
payload ->> 'action' AS action,
(payload ->> 'duration_ms')::INTEGER AS duration_ms
FROM events;
拿博客给的样例数据跑,结果没问题:
┌───────┬─────────┬──────────┬─────────────┐
│ id │ country │ action │ duration_ms │
├───────┼─────────┼──────────┼─────────────┤
│ 1 │ NL │ click │ NULL │
│ 2 │ BE │ view │ 1200 │
│ 3 │ NULL │ purchase │ NULL │
└───────┴─────────┴──────────┴─────────────┘
两个毛病:字段一多,SQL 又长又难维护;更重要的是每抽取一个字段,那份 JSON 就被完整解析一次,三个字段就是三次解析。说实话,偶尔回答一个问题无所谓,天天跑的报表受不了。
● ● ●
三步法:结构自动推断,类型自己修
整条流水线长这样:
JSON 列转类型化列的三步流水线
第一步:推断整列的结构
json_group_structure 是 json_structure 的聚合版本,把一列里所有 JSON 值的结构合并成一个:
SELECT json_group_structure(payload::JSON) AS structure
FROM events;
注意任一行出现过的键都会被抓到,包括只有第二行才有的 duration_ms 和只有第三行才有的 items:
{"user":{"id":"UBIGINT","country":"VARCHAR"},"action":"VARCHAR",
"ts":"VARCHAR","duration_ms":"UBIGINT",
"items":[{"sku":"VARCHAR","qty":"UBIGINT"}]}
表大的时候不必全表扫,官方建议抽样推断,比如 FROM events USING SAMPLE 10_000 ROWS。我在本地试了把样本压到 2 行,抽到的两行恰好都没有 duration_ms,推断结果里这一列直接消失了。抽样省的是扫描,漏的是稀有键——键的分布越稀疏,样本要越大,这是官方博客只用一句话带过、实测才会记住的细节。
第二步:修类型
推断结果需要手工修两处:JSON 没有时间戳类型,ts 出来是 VARCHAR;整数出来是偏宽的 UBIGINT。结构本身就是一份 JSON 文档,所以直接用 json_merge_patch 打补丁,只写要改的键,没写的保持推断值:
SET VARIABLE payload_structure = (
SELECT json_merge_patch(
json_group_structure(payload::JSON),
'{
"user": {"id": "INTEGER"},
"ts": "TIMESTAMP",
"duration_ms": "INTEGER",
"items": [{"sku": "VARCHAR", "qty": "INTEGER"}]
}'
)
FROM events
);
第三步:一次解析,摊开成列
json_transform(别名 from_json)按给定结构把 JSON 值转成 DuckDB 的 STRUCT,unnest 再把结构体的顶层字段摊成列:
CREATE TABLE events_typed AS
SELECT id, unnest(json_transform(payload, getvariable('payload_structure')))
FROM events;
DESCRIBE events_typed;
本地跑出来的表结构,嵌套的 user 和数组里的 items 都各归其位:
│ id │ INTEGER │
│ action │ VARCHAR │
│ user │ STRUCT(country VARCHAR, id INTEGER) │
│ ts │ TIMESTAMP │
│ duration_ms │ INTEGER │
│ items │ STRUCT(sku VARCHAR, qty INTEGER)[] │
从这里开始,JSON 只在装表时解析了一次。之后的列是普通 DuckDB 列,按类型存储和压缩,查询根本碰不到 JSON 解析器。字段访问也变成了原生的路径写法:
SELECT id, "user".id AS user_id, "user".country, action, ts
FROM events_typed;
● ● ●
脏数据:宽松版静默置空,严格版当场报错
默认的 json_transform 遇到转不动的值不报错,直接给 NULL:
SELECT json_transform('{"qty": "two"}', '{"qty": "INTEGER"}');
-- {'qty': NULL}
对脏数据友好,但也可能把问题藏起来。调结构的时候换 json_transform_strict,本地实测它对同样的输入当场抛错:
Invalid Input Error: Failed to cast value to numerical: "two"官方建议的节奏是:先用严格版把结构调对,确认哪些脏值可以容忍后,再切回宽松版上生产。
● ● ●
那原始列该存 VARCHAR 还是 JSON?
DuckDB 的 JSON 类型底层存储和 VARCHAR 完全一样,大小和扫描速度没有差别。它多给的是写入时的校验(非法文档进不来)和抽取函数不用处理畸形输入的保证。
两种做法的差别不在存储,在什么时候付解析的钱:
逐字段抽取与转类型列的成本对比
如果这个列会被反复查询,原始列是 VARCHAR 还是 JSON 其实无所谓,要紧的是字段有没有被抽出来。查询期抽取,每个查询都重付一遍解析的钱;装表时转一次,这笔钱只付一次。
回到开头那种 VARCHAR 列:它不必继续当「只能抠着查」的半成品数据。三条 SQL,推断、修补、摊开,它就成了真正能跑满速的表。官方把方法写清楚了,剩下的只是你愿不愿意让那列 JSON 只被解析一次。
参考来源
- 01
From JSON Strings to Typed Columns — The DuckDB team, 2026-10-16
https://duckdb.org/2026/10/16/json-to-typed-columns.html
- 01
Reconciling JSON in DuckDB, One Patch at a Time(json_merge_patch 专题)
https://duckdb.org/2026/08/18/json-reconciling-v2.html
- 01
本地验证环境:DuckDB 1.6.0.dev343(Python 包),所有 SQL 与输出均为实测
你手头那列 JSON 一般怎么处理?A 一直用抽取操作符凑合;B 早就导出成文件再读回来;C 转成过类型列但吃过后缀名对不上的亏。评论区说说,我看看哪种最常见。