返回

DuckDB:将 JSON 字符串列转为类型化列

RSS DuckDB Engineering Blog AI 逐段翻译 发布 2026-10-16 08:00 收录于 10-17
DataHot 速览

DuckDB 官方博客介绍如何不经过 COPY 到文件再 read_json 的绕路,直接在 SQL 中把表中 VARCHAR 类型的 JSON 列转换为类型化列。示例 events 表包含 3 行事件数据,payload 字段并不一致,例如 duration_ms 仅第 2 行有、items 仅第 3 行有、country 第 3 行缺失。文章先展示用 -> 和 ->> 提取少量字段,再说明可推断并调整 schema 后展开为类型化列。该方法适用于 Postgres 事件 payload、API 响应、CSV 元数据列等以文本列存储 JSON 的场景。

为什么值得关注:对数据工程师和分析工程师有直接参考价值:它给出了一种避免文件往返、直接在 SQL 中处理 JSON 文本列并生成类型化列的实用模式。

本文目录 11 节
  1. 从 JSON 字符串到类型化列
  2. 数据
  3. 选取少量字段
  4. 从列推断结构
  5. 调整类型
  6. 构建表
  7. 嵌套结构体与名称冲突
  8. 对象数组
  9. 不适配的值
  10. 该列应该是 VARCHAR 还是 JSON?
  11. 进一步阅读

译文

AI 逐段翻译

从 JSON 字符串到类型化列

2026-10-16 6 分钟阅读

DuckDB 团队

TL;DR:JSON 通常以表中文本列的形式到达,而不是作为文件。在这篇文章中,我们从这样的列推断出模式,对其进行调整,并将该列转换为类型化列,而无需先将数据写入文件。

DuckDB 的 read_json 可将 JSON 文件转换为表。它读取部分数据以推断出模式,然后为你提供类型化列。然而,大量 JSON 并不是以文件形式出现的。它以文本列的形式出现:Postgres 表中的事件负载、摄取作业存储的 API 响应、CSV 导出中的 metadata 列。

人们经常询问如何为此类列获得 read_json 式的处理,而出现的一种变通方法是,将该列 COPY 到文件,然后再把文件读回来。

你不需要这样往返一圈。在这篇文章中,我们直接在 SQL 中得到相同的结果。

数据

这里是一个小的事件表,其中负载以 VARCHAR 存储:

CREATETABLEevents(idINTEGER,payloadVARCHAR);INSERTINTOeventsVALUES(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 路径:

SELECTid,payload->>'$.user.country'AScountry,payload->>'action'ASaction,(payload->>'duration_ms')::INTEGERASduration_msFROMevents;
┌───────┬─────────┬──────────┬─────────────┐
│  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 列:

SELECTjson_group_structure(payload::JSON)ASstructureFROMevents;
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│                                                                      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 覆盖其中的部分内容。将结果存储在一个 变量 中,以便在下一个查询中使用:

SETVARIABLEpayload_structure=(SELECTjson_merge_patch(json_group_structure(payload::JSON),'{
            "user": {"id": "INTEGER"},
            "ts": "TIMESTAMP",
            "duration_ms": "INTEGER",
            "items": [{"sku": "VARCHAR", "qty": "INTEGER"}]
        }')FROMevents);

补丁只列出你想更改的内容。你省略的键,例如 user.country 和 action,会保留其推断出的类型。

构建表

json_transform(也可作为 from_json 使用)会按照你给出的结构,将一个 JSON 值转换为 DuckDB 的 STRUCT。unnest 随后将该结构体的顶层字段展开为列:

CREATETABLEevents_typedASSELECTid,unnest(json_transform(payload,getvariable('payload_structure')))FROMevents;DESCRIBEevents_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:

SELECTid,"user".idASuser_id,"user".country,action,tsFROMevents_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 │
└───────┴─────────┴─────────┴──────────┴─────────────────────┘

嵌套结构体与名称冲突

使用 unnest(..., recursive := true) 进行递归展开,可以在一次操作中展平嵌套结构体。默认情况下,它按最内层的键为每一列命名,因此 id 在 user 中最终成为名为 id 的列,紧挨着事件自身的 id:

SELECTid,unnest("user",recursive:=true)FROMevents_typedLIMIT1;
┌───────┬─────────┬───────┐
│  id   │ country │  id   │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     1 │ NL      │    17 │
└───────┴─────────┴───────┘

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

SELECTid,"user.id","user.country"FROM(SELECTid,unnest(json_transform(payload,getvariable('payload_structure')),recursive:=true,keep_parent_names:=true)FROMeventsLIMIT1);
┌───────┬─────────┬──────────────┐
│  id   │ user.id │ user.country │
│ int32 │  int32  │   varchar    │
├───────┼─────────┼──────────────┤
│     1 │      17 │ NL           │
└───────┴─────────┴──────────────┘

对象数组

在 events_typed 中,items 数组存储为 STRUCT(sku VARCHAR, qty INTEGER)[],即结构体列表,正如上面的 DESCRIBE 输出所示。要每个项一行,需在 unnest 子句中展开该列表:FROM 子句中展开该列表:

SELECTid,u.item.sku,u.item.qtyFROMevents_typed,unnest(items)ASu(item);
┌───────┬─────────┬───────┐
│  id   │   sku   │  qty  │
│ int32 │ varchar │ int32 │
├───────┼─────────┼───────┤
│     3 │ A-1     │     2 │
│     3 │ B-7     │     1 │
└───────┴─────────┴───────┘

没有 items 的事件不会出现在这里。如果你需要保留它们,请将 unnest 写为横向连接:FROM events_typed LEFT JOIN unnest(items) AS u(item) ON true。

不适配的值

默认情况下,json_transform 不会对无法转换的值引发错误。该值会改为 NULL:

SELECTjson_transform('{"qty": "two"}','{"qty": "INTEGER"}')ASresult;
┌─────────────────────┐
│       result        │
│ struct(qty integer) │
├─────────────────────┤
│ {'qty': NULL}       │
└─────────────────────┘

这对于杂乱数据很方便,但它可能掩盖问题。json_transform_strict 则会引发错误:

SELECTjson_transform_strict('{"qty": "two"}','{"qty": "INTEGER"}')ASresult;
Invalid Input Error: Failed to cast value to numerical: "two"

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

该列应该是 VARCHAR 还是 JSON?

这个问题在 DuckDB 讨论中经常出现。JSON 类型 的存储方式与 VARCHAR 完全相同,因此在大小或扫描速度上没有差异。它增加的是写入时的验证,因为无效文档会导致插入失败,并且保证提取函数不必处理格式错误的输入。

如果你会多次从该列查询字段,原始列的类型远不如你是否提取字段重要。查询时解析 JSON 是每次查询都必须重复进行的工作。如上面所示,一次性将其转换为类型化列,这项工作就只做一次。

进一步阅读

有关在 DuckDB 中处理 JSON 的更多信息,请参阅以下博客文章和文档页面:

这篇内容对你有用吗?

反馈只用于改善内容筛选,不等同于收藏

分享这条资讯
分享海报
保存图片
iOS 也可以长按图片保存