DeepSeek总结的DuckDB 从 JSON 字符串到类型化列

从 JSON 字符串到类型化列

2026-10-15 6 分钟阅读

DuckDB 团队

TL;DR: JSON 经常以表中的文本列形式到达,而不是作为文件。在本文中,我们从这样的列推断 schema,调整它,并把该列变成类型化列,而无需先把数据写入文件。

DuckDB 的 read_json 把 JSON 文件变成表。它读取部分数据来推断 schema,然后给你类型化列。然而,很多 JSON 不是以文件形式到来的。它以文本列的形式到来:Postgres 表中的事件负载、摄取作业存储的 API 响应、CSV 导出中的元数据列。

人们经常问如何让这样的列得到 read_json 的待遇,而出现的一个变通方法是把该列 COPY 到文件再读回来。

你不需要这个往返。在本文中,我们直接在 SQL 中得到相同的结果。

数据

这是一张小的 events 表,payload 存储为 VARCHAR:

sql 复制代码
CREATE TABLE events (id INTEGER, payload VARCHAR);
INSERT INTO events VALUES
    (1, '{"user": {"id": 17, "country": "NL"}, "action": "click", "ts": "2026-10-01 10:00:00"}'),
    (2, '{"user": {"id": 23, "country": "BE"}, "action": "view", "ts": "2026-10-01 10:00:05", "duration_ms": 1200}'),
    (3, '{"user": {"id": 17}, "action": "purchase", "ts": "2026-10-01 10:02:11", "items": [{"sku": "A-1", "qty": 2}, {"sku": "B-7", "qty": 1}]}');

像大多数真实负载一样,这些对象并不都有相同的键。只有第二个事件有 duration_ms,只有第三个有 items,而第三个没有 country。

挑出几个字段

如果你只需要少量字段,JSON 提取运算符就够了。-> 返回 JSON,->> 返回 VARCHAR。两者都接受键名或 JSON 路径:

sql 复制代码
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 │
│ int32 │ varchar │ varchar  │    int32    │
├───────┼─────────┼──────────┼─────────────┤
│     1 │ NL      │ click    │        NULL │
│     2 │ BE      │ view     │        1200 │
│     3 │ NULL    │ purchase │        NULL │
└───────┴─────────┴──────────┴─────────────┘

缺失的键以 NULL 返回,这正是你希望对可选字段得到的。然而,这种方法为每个提取的字段再次解析 JSON,而一旦你想要超过几列,它就变得冗长。

从列推断结构

json_structure 返回单个 JSON 值的结构。它的聚合变体 json_group_structure 把一列中所有值的结构合并成一个。它需要 JSON 输入,所以先转换 VARCHAR 列:

sql 复制代码
SELECT json_group_structure(payload::JSON) AS structure
FROM events;
复制代码
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                      structure                                                                      │
│                                                                        json                                                                         │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ {"user":{"id":"UBIGINT","country":"VARCHAR"},"action":"VARCHAR","ts":"VARCHAR","duration_ms":"UBIGINT","items":[{"sku":"VARCHAR","qty":"UBIGINT"}]} │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

任何行中出现的每个键都在里面,包括 duration_ms 和 items 数组。

对于一张大表,你不必为此扫描每一行。在样本上运行它,例如 FROM events USING SAMPLE 10_000 ROWS,要便宜得多,代价是可能错过非常罕见的键。

调整类型

推断出的结构需要几处修正。ts 出来是 VARCHAR,因为 JSON 没有时间戳类型,而整数出来是 UBIGINT,比需要的更宽。

由于结构本身就是一个 JSON 文档,你可以用 json_merge_patch 覆盖它的部分。把结果存到一个变量中,以便在下一个查询中使用:

sql 复制代码
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
);

补丁只列出你想改变的东西。你省略的键,比如 user.country 和 action,保持它们推断出的类型。

构建表

json_transform(也可以作为 from_json 使用)按照你给它的结构把 JSON 值转换为 DuckDB STRUCT。unnest 然后把该 struct 的顶层字段展开为列:

sql 复制代码
CREATE TABLE events_typed AS
    SELECT id, unnest(json_transform(payload, getvariable('payload_structure')))
    FROM events;

DESCRIBE events_typed;
复制代码
┌─────────────┬─────────────────────────────────────┬─────────┬─────────┬─────────┬─────────┐
│ column_name │             column_type             │  null   │   key   │ default │  extra  │
│   varchar   │               varchar               │ varchar │ varchar │ varchar │ varchar │
├─────────────┼─────────────────────────────────────┼─────────┼─────────┼─────────┼─────────┤
│ id          │ INTEGER                             │ YES     │ NULL    │ NULL    │ NULL    │
│ action      │ VARCHAR                             │ YES     │ NULL    │ NULL    │ NULL    │
│ user        │ STRUCT(country VARCHAR, id INTEGER) │ YES     │ NULL    │ NULL    │ NULL    │
│ ts          │ TIMESTAMP                           │ YES     │ NULL    │ NULL    │ NULL    │
│ duration_ms │ INTEGER                             │ YES     │ NULL    │ NULL    │ NULL    │
│ items       │ STRUCT(sku VARCHAR, qty INTEGER)[]  │ YES     │ NULL    │ NULL    │ NULL    │
└─────────────┴─────────────────────────────────────┴─────────┴─────────┴─────────┴─────────┘

JSON 在加载时被解析一次。从这里开始,列是常规的 DuckDB 列,按类型存储和压缩,对它们的查询完全不碰 JSON:

sql 复制代码
SELECT id, "user".id AS user_id, "user".country, action, ts
FROM events_typed;
复制代码
┌───────┬─────────┬─────────┬──────────┬─────────────────────┐
│  id   │ user_id │ country │  action  │         ts          │
│ int32 │  int32  │ varchar │ varchar  │      timestamp      │
├───────┼─────────┼─────────┼──────────┼─────────────────────┤
│     1 │      17 │ NL      │ click    │ 2026-10-01 10:00:00 │
│     2 │      23 │ BE      │ view     │ 2026-10-01 10:00:05 │
│     3 │      17 │ NULL    │ purchase │ 2026-10-01 10:02:11 │
└───────┴─────────┴─────────┴──────────┴─────────────────────┘

嵌套 Struct 和名称冲突

用 unnest(..., recursive := true) 进行递归展开会在一次遍历中展平嵌套 struct。默认情况下,它按最内层的键命名每一列,所以 user 里面的 id 最终成为一个名为 id 的列,紧挨着事件自己的 id:

sql 复制代码
SELECT id, unnest("user", recursive := true)
FROM events_typed
LIMIT 1;
复制代码
┌───────┬─────────┬───────┐
│  id   │ country │  id   │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     1 │ NL      │    17 │
└───────┴─────────┴───────┘

有两个列都叫 id,后续查询无法分辨你指的是哪一个。传入 keep_parent_names := true 给每列加上它来自的键作为前缀,这样来自不同层级的名称保持区分。在完整负载上,user.id 和 user.country 不再与顶层列冲突,你可以按它们带前缀的名称选择它们:

sql 复制代码
SELECT id, "user.id", "user.country"
FROM (
    SELECT id, unnest(json_transform(payload, getvariable('payload_structure')), recursive := true, keep_parent_names := true)
    FROM events
    LIMIT 1
);
复制代码
┌───────┬─────────┬──────────────┐
│  id   │ user.id │ user.country │
│ int32 │  int32  │   varchar    │
├───────┼─────────┼──────────────┤
│     1 │      17 │ NL           │
└───────┴─────────┴──────────────┘

对象数组

在 events_typed 中,items 数组存储为 STRUCT(sku VARCHAR, qty INTEGER)[],一个 struct 列表,如上面的 DESCRIBE 输出所示。要每个 item 一行,在 FROM 子句中展开列表:

sql 复制代码
SELECT id, u.item.sku, u.item.qty
FROM events_typed, unnest(items) AS u(item);
复制代码
┌───────┬─────────┬───────┐
│  id   │   sku   │  qty  │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     3 │ A-1     │     2 │
│     3 │ B-7     │     1 │
└───────┴─────────┴───────┘

没有 items 的事件不在这里出现。如果你需要保留它们,把 unnest 写成 lateral join:FROM events_typed LEFT JOIN unnest(items) AS u(item) ON true。

不匹配的值

默认情况下,json_transform 对无法转换的值不报错。该值变成 NULL:

sql 复制代码
SELECT json_transform('{"qty": "two"}', '{"qty": "INTEGER"}') AS result;
复制代码
┌─────────────────────┐
│       result        │
│ struct(qty integer) │
├─────────────────────┤
│ {'qty': NULL}       │
└─────────────────────┘

这对混乱数据很方便,但它可能隐藏问题。json_transform_strict 则报错:

sql 复制代码
SELECT json_transform_strict('{"qty": "two"}', '{"qty": "INTEGER"}') AS result;
复制代码
Invalid Input Error: Failed to cast value to numerical: "two"

在你研究结构时使用严格版本,一旦你知道愿意接受哪些怪东西,就切换到宽松版本。

列应该是 VARCHAR 还是 JSON?

这个问题经常在 DuckDB 讨论中出现。JSON 类型的存储方式与 VARCHAR 完全相同,所以大小或扫描速度没有区别。它增加的是输入时的验证,因为无效文档会使插入失败,以及提取函数不必处理畸形输入的保证。

如果你会多次从该列查询字段,原始列的类型远不如你是否提取字段重要。在查询时解析 JSON 是每个查询都必须重复的工作。一次性把它变成类型化列,如上所示,就只做一次这项工作。

进一步阅读

关于在 DuckDB 中处理 JSON 的更多内容,参见以下博客文章和文档页面:

  • 《Shredding Deeply Nested JSON, One Vector at a Time》,关于 DuckDB 如何读取 JSON 文件
  • 《Reconciling JSON in DuckDB, One Patch at a Time》,关于用 json_merge_patch 组合 JSON 文档
  • JSON 概述和 JSON 函数
  • JSON 类型
  • unnest 和 STRUCT 数据类型
相关推荐
Raas1001 小时前
AI网关能做调用审计吗?MAI Gateway(魔芋企业级AI网关)实战能力深度解读
java·服务器·网络·数据库·gateway·prompt·企业级产品
foolishlee1 小时前
openGauss同步提交
数据库
bksczm1 小时前
MySQL进阶篇之备份(下半篇)
数据库·mysql
计算机就业还在扩大1 小时前
Redis中的setnx是原子性的?
数据库·redis·缓存
StarRocks_labs1 小时前
StarRocks 接入 Paimon C++:让湖上查询进入原生执行链路
starrocks·c++·sql·jni·paimon·auto·湖上查询
updayday8541 小时前
供应商补交资质文件时,采购难以核对缺件和新版本 - Ping64 文件收集
大数据·网络·数据库·安全·智能路由器
小师兄吃牛肉2 小时前
SQL 编辑器异常排查:基于 KStudio 的一次实战记录
java·sql·编辑器
PaperData2 小时前
2012-2025年省市区县政府周边的夜间灯光亮度及是否加班数据
数据库
Light602 小时前
四款云端 Agent 的安全边界画在三个地方
大数据·数据库·安全·个人agent·安全边界·提示注入·grok bot