
先说我最近遇到的一件事。数据平台跑完一批用户行为特征产出的是一个接近 10GB 的 Parquet 文件按天分区一共几十个小文件。下游业务方却说他们只需要 JSONL因为他们要拿去做事件流回放还要直接在命令行里用 jq 看明文。我当时的第一个反应是这不就是把 Parquet 一行行读出来再写成 JSON 字符串吗真做起来才发现事情远没有“三行代码搞定”那么简单。好在这个需求非常典型——把 Parquet 转成 JSONL在 Python 生态里就是一个“会者不难”的活难的是你得知道每一步背后发生了什么才知道什么时候该用哪种写法。这篇文章就把我从头到尾踩过的坑、验证过的方案、以及最终沉淀下来的生产级写法完整讲一遍。不管你是刚入门 Python 的初学者还是已经在数据管道里摸爬滚打了几年只要手头有 Parquet 文件需要转成 JSONL这篇文章的代码和经验都能直接拿去用。1. 这个转换需求是怎么来的以及两个格式的本质差异先说人话Parquet 是给机器看的高压缩列式存储JSONL 是给人看的一行一个 JSON 对象的文本格式。两者没有谁替代谁它们活在完全不同的工作流里。我见过最常见的转换场景有几种第一种模型训练前要把特征数据切成更小的样本集业务方说“文件太大我看不了你转成 JSONL 我抽几行看看”第二种日志采集链路的上游只认 JSONL你要把数仓里的 Parquet 结果导出给采集 agent第三种做数据交换外部合作方不能用你的数据湖内核他们只想要一个不依赖任何框架就能读的文本文件第四种要把数据导入 MongoDB、Elasticsearch 这类对这种“每行一个文档”格式天然友好的系统。理解了需求之后就能理解为什么转换不是无脑 copy 一下格式就完事。Parquet 是列式存储把相同类型的数据扎堆存放配合 snappy 或 zstd 压缩压缩率可以做到文本格式的十分之一甚至更低。而 JSONL 是行式文本每一行必须自包含意味着原来的压缩优势基本归零。转换的过程本质上做了三件事一是按行展开列式存储二是把 Arrow 的类型系统映射成 JSON 的类型系统三是重新做文本序列化。这三个环节里最容易出问题的是第二个。Arrow 里有 Timestamp、Decimal、List、Struct、Dictionary、二进制还有 NaN、null、inf 这些特殊值而 JSON 只有 null、数字、布尔、字符串、数组、对象这几样。怎么把前者安全无损地映射到后者就是这篇文章的核心。另外值得一提的是一次“反向直觉”的观察很多人以为格式转换最怕的是数据量大其实不是。数据量再大也只是时间问题真正让任务跑挂的往往是单行数据里的某种特殊类型。我后面会单独用一个章节讲这个因为这块网上能查到的资料少而且报错信息往往非常误导人。1.1 常见工具链速览在 Python 生态里做这个转换的“标准套餐”其实就是两个库pandas 和 pyarrow。性能敏感的场景还会看到 duckdb、polars、spark 的身影。方案依赖推荐场景主要限制pandas 的 read_parquet to_jsonpandas, pyarrow中小文件、快速交付内存需要能装下一份半数据嵌套类型处理麻烦pyarrow 的 ParquetFile iter_batchespyarrow生产环境、大文件序列化细节需要自己写duckdb 的 COPY 语句duckdb超大文件、想少写代码嵌套类型和特殊值的行为是你不太好控制的polars / sparkpolars / pyspark集群或超大数据量引入成本高小需求没必要这篇文章主要讲前两种因为它们是绝大多数 Python 工程师最顺手的路线。duckdb 方案我会在性能优化章节提到但不会主推——它能让你跑得很快但遇到类型转换的边角情况时你会发现自己很难插手。2. 转换前的准备环境、依赖与文件体检这个环节经常被人跳过但我强烈建议你不要跳。因为大多数转换失败都不是代码写错而是你根本不了解手头的 Parquet 文件长什么样。环境方面只需要一个 Python 3.9 以上解释器然后装好 pandas 和 pyarrow。没有别的前置条件不需要装 Hadoop 生态的任何东西。pip install pandas pyarrow装完以后第一步永远应该是体检文件。我会写一个十几行的脚本把文件的元信息打出来先确认里面到底有什么类型而不是直接闷头转换。import pyarrow.parquet as pq import sys def inspect_parquet(path: str) - None: pf pq.ParquetFile(path) schema pf.schema_arrow print(f文件路径: {path}) print(f总行数: {pf.metadata.num_rows}) print(fRowGroup 数量: {pf.metadata.num_row_groups}) print(fSchema 字段数: {len(schema.names)}) print() for field in schema: print(f {field.name}: {field.type}) if __name__ __main__: inspect_parquet(sys.argv[1])这段脚本的运行结果会直接告诉你很多东西。比如我上次遇到的一个文件跑完发现有一列是listitem: structname: string, score: double这种嵌套结构如果直接用 pandas 的read_parquet去读会被 Arrow 自动压成item.name、item.score这样的扁平列名原始嵌套结构完全丢掉了。等到转 JSONL 的时候输出就跟你期望的完全不一样而且你根本不知道是哪一步改的。2.1 为什么要做文件体检因为 Parquet 是二进制格式你不打开它就永远不知道里面藏着什么。我用“体检”这个词是有原因的。Parquet 文件里除了数据还有一层非常重要但平常看不见的东西——Schema。它精确记录了每一列的类型、压缩方式、编码方式甚至嵌套结构。这些信息决定了你怎么读它也决定了你怎么写序列化逻辑。你提前知道了字段类型后面遇到怪异的报错时就能快速判断是哪一列惹的祸。比如跟合作方对接时我习惯把体检脚本的输出直接贴给对方确认“我要按这个 schema 转细节你过目一下。”这个动作能挡掉至少一半的返工。因为实际业务里的 Parquet 文件跟业务方口头告诉你的字段结构经常是两码事。2.2 关于引擎的选择pandas 还是 pyarrow我见过太多初学者一上来就pip install pandas然后pd.read_parquet(...)。这不是不能用而是你得知道自己把命运交给了谁。pandas 的read_parquet底层有两个引擎pyarrow和fastparquet。默认情况下pandas 会优先用 pyarrow。而 pyarrow 读取 Parquet 时是把数据先转成 Arrow 表再在需要的时候转成 pandas DataFrame。这个转化过程不是全免费的尤其遇到复杂嵌套类型时Arrow 到 pandas 的类型映射会丢失一部分原始信息。我的建议是如果你最终要转 JSONL那就别绕道 pandas直接用 pyarrow 处理到序列化的最后一公里再碰 pandas 或者干脆不碰。因为 pandas 的to_json虽然写着方便但它对你手头的数据类型有自己的理解安全性和可控性都不如直接操作 Arrow 的 batch。3. Pandas 路线实操最直观但你要知道它帮你扛了什么先说这条路线为什么“直观”。因为它只需要两个方法pd.read_parquet()和df.to_json(orientrecords, linesTrue)。看着确实是三行搞定但如果你文件一上来就几个 GB这个方案很可能直接吃光你的内存。为什么因为 pandas 在读取时会在内存里构建一份完整的 DataFrame而to_json序列化时即使你按行写它其实也会先在内存里生成一个完整的 JSON 字符串列表然后再拼接。数据量一大这个过程的内存消耗可能是文件原始大小的好几倍。举个例子一个 1GB 的 Parquet 文件压缩前数据可能有 3-4GB读成 DataFrame 占一份转成 JSON 字符串再占一份加上中间过程产生的临时对象16GB 内存的机器直接干到 swap。我见过不只一次同事在本地跑这种脚本电脑风扇狂转最后进程被杀。import pandas as pd df pd.read_parquet(input.parquet) with open(output.jsonl, w, encodingutf-8) as f: f.write(df.to_json(orientrecords, linesTrue, force_asciiFalse))如果文件不大、内存够用、一次性的任务这段代码足够。但我要提醒几个参数orientrecords表示每一行是一个对象这是 JSONL 的标准形态。linesTrue必须配合orientrecords使用这样输出的是每行一个 JSON 对象而不是一整个 JSON 数组。force_asciiFalse让中文字符直接输出而不是转成\uXXXX文件体积会小很多可读性也强得多。但即使解决了输出格式嵌套列你依然绕不过去。pandas 读进来的嵌套结构已经被拍平了你再怎么调to_json的参数也恢复不了原来的嵌套关系。所以要把这条路线真正用对你需要给它加一个“保留嵌套”的前置条件用 pyarrow 读取转 pandas 时显式传types_mapper或者用to_pydict再组装否则你会得到一个结构正确但嵌套完全丢失的结果。这就不如老老实实走 pyarrow 的自定义路线。3.1 什么时候可以用 Pandas 路线我给一个实用判断标准只要你的文件在单个 GB 级别以下schema 里没有复杂的嵌套类型且你只是临时转一次那 pandas 路线完全没问题。它快、简单、可读性强尤其在本地调试、给同事发小样本的时候效率极高。如果超过了这个规模或者嵌套类型很多就往下看 PyArrow 路线。这里多说一句实际生产环境里“单个 GB 级别”听上去小但 Parquet 压缩率很高1GB 的 Parquet 解压出来可能是 5-10GB 的文本。所以判断依据不是文件名大小而是你预估解压后的 JSON 有多大以及你机器的内存能撑到多少。4. PyArrow 路线实操生产级写法与完整代码这是我最推荐的生产级方案。核心思想是不要一次性把整个文件读进内存而是利用ParquetFile.iter_batches()按批次读取转一行写一行全程内存占用保持在一个稳定的小区间。先给完整代码然后拆开讲为什么这么写。import json import math from pathlib import Path from typing import Any, Callable, Iterator import pyarrow as pa import pyarrow.parquet as pq def _default_serializer(obj: Any) - str: 处理 JSON 无法直接序列化的 Arrow/Parquet 类型。 # 日期时间类型 if hasattr(obj, isoformat): return obj.isoformat() # bytes 类型常见于 Parquet 的 binary 字段 if isinstance(obj, bytes): return obj.decode(utf-8, errorsreplace) # Decimal 类型转成字符串可以避免精度丢失 if isinstance(obj, decimal.Decimal): return str(obj) raise TypeError(f无法序列化类型: {type(obj)}) def parquet_to_jsonl( src_path: str | Path, dst_path: str | Path, batch_size: int 50_000, ensure_ascii: bool False, custom_serializer: Callable[[Any], str] | None None, ) - int: pf pq.ParquetFile(src_path) serializer custom_serializer or _default_serializer total_write 0 with open(dst_path, w, encodingutf-8) as out_f: for batch in pf.iter_batches(batch_sizebatch_size): # 关键步骤把 Arrow RecordBatch 转成 Python 字典列表 records batch.to_pydict() # 将按列存储的 dict 转成按行存储的 list再逐行 JSON 化 for i in range(batch.num_rows): row {key: records[key][i] for key in records.keys()} line json.dumps( row, ensure_asciiensure_ascii, allow_nanFalse, separators(,, :), defaultserializer, ) out_f.write(line \n) total_write 1 return total_write这段代码看起来量不大但里面每一个选择都有讲究。我挨个讲。4.1 为什么用iter_batches而不是read_tableiter_batches(batch_size...)是 PyArrow 提供的一个惰性迭代器它不会一次性把整个文件加载进来而是按指定的行数切块。你处理完一个 batch这块数据就可以被垃圾回收了。内存曲线就是一条水平直线不管底层文件是 1GB 还是 100GB占用的内存基本恒定。这里要注意一个容易被忽略的点batch_size并不是越大越好。50,000 行是一个比较中庸的选择原因有两个。一是batch.to_pydict()会一次性构造一个按列的 Python dict行数越多这个 dict 占的内存越大Python 对象的开销远高于 Arrow 内部的连续内存二是批量太大会增加单批次处理时间一旦中途发生异常你要损失的计算量就更大。另外iter_batches内部会在 RowGroup 之间自动切换你不需要管 RowGroup 是什么。你只需要明白一个概念Parquet 文件在物理上被切成了若干个 RowGroup每个 RowGroup 内部的数据是连续存储的。iter_batches读取时可能会跨 RowGroup这只是性能差异的问题不影响输出正确性。4.2 为什么用to_pydict()而不是to_pandas()很多人习惯先转 pandas再to_dict。但这里有个隐藏的坑当 Parquet 里有嵌套结构时batch.to_pandas()默认会把 struct 类型展开成多列原始层级关系丢失。而batch.to_pydict()是直接按 Arrow 的内存结构输出 Python 对象嵌套结构原样保留不会拍平。举个例子如果你的 Parquet 里有一列是Structname: string, score: double那么to_pydict()会把它转成{field: [{name: 张三, score: 92.5}, {name: 李四, score: 88.0}]}而如果走了to_pandas()你得到的可能是{ field.name: [张三, 李四], field.score: [92.5, 88.0] }这两个结果对下游来说完全不是一回事。前者是一个完整的对象后者是拍平后的列名。所以在转换 JSONL 这种天然需要结构自包含的格式时请一定用to_pydict()。它的代价是性能。to_pydict()逐元素地把 Arrow 数组转成 Python 对象会损失一部分 C 层的性能优势。但在生产场景里这个损失的性价比是划算的因为你换来的是类型可控、嵌套保留、零意外。4.3json.dumps的参数都是干什么的json.dumps有四个参数值得说清楚ensure_asciiFalse是为了让中文直接以明文输出而不是\u4e2d\u6587。文件体积会明显减小查看时也更直观。但要注意如果你的下游系统对编码有严格限制或者你的文件要进入某些老旧的传输管道保留默认的ensure_asciiTrue可能是更安全的选择。separators(,, :)是为了压缩 JSON 字符串体积。默认情况下json.dumps会在键值对之间加空格对于千万行级别的文件这个空格会白白多出几百 MB 的磁盘空间。压缩掉之后人还是能一眼看懂文件却小了一圈。allow_nanFalse是一个“强制暴露问题”的开关。Python 的json模块默认允许NaN、Infinity、-Infinity这种非标准 JSON 值但你输出给下游它们很可能无法解析。设置成False之后一旦数据里存在 NaN 或 Infinity脚本会立刻抛异常。这其实是好事——它帮你在转换阶段发现问题而不是等下游解析失败再去排查。defaultserializer是一个兜底函数处理 JSON 不认识的对象比如datetime、bytes、Decimal。我在_default_serializer里写了几个最常见的分支你也可以按自己的数据情况扩展。4.4 关于逐行写入的性能问题我知道有人会问逐行out_f.write是不是太慢了这里其实有两个层面。第一json.dumps的耗时远大于文件写入所以瓶颈在序列化而非写文件第二Python 的write调用虽然看起来是逐行但因为有操作系统层面的缓冲实际落盘次数远没有行数那么多。我在单机上一个 1200 万行的文件用这段代码跑完大约 4 分钟其中绝大部分时间花在json.dumps上。如果你实在想优化可以改写成批量拼接再写入buf [] for i in range(batch.num_rows): ... buf.append(line) out_f.write(\n.join(buf) \n)这种方式能减少 Python 层write调用的次数对吞吐量有一点帮助。但内存占用会稍微高一点因为你要在内存里持有整个 batch 的 JSON 字符串。建议先跑一次最朴素的版本如果性能可以接受就别过度优化。5. 类型映射与序列化转换过程中最容易踩的五个坑前面虽然介绍了 PyArrow 路线的完整代码但你直接拿去跑真实业务数据大概率还是会在某个奇怪的地方挂掉。我总结了五个我自己踩过、也在同事那里反复出现的坑。每一个我都给出了“表现、原因、解决办法”三段式方便你直接定位。5.1 NaN、null 和 InfinityJSON 里没有的东西这是最经典的一个坑。Parquet 文件里的浮点列经常存在空值或者缺失值。在 Arrow 里这些值会被表示成 null但当你用to_pydict()转成 Python 对象时可能会变成nan、None、inf等不同的值。json.dumps的allow_nanFalse会对非标准浮点数直接抛异常但 null 是合法的。解决办法要看你的下游期望。如果缺失值应该输出成null那最简单的方式是在序列化前做一次清洗import math def sanitize_row(row: dict) - dict: for key, value in row.items(): if isinstance(value, float) and math.isnan(value): row[key] None return row注意这里math.isnan只处理浮点数不会误伤字符串。如果值本身是整数不需要管。如果你用的是 pandas 路线可以考虑df df.where(pd.notnull(df), None)但前提是你要确保 DataFrame 里的 null 不会被 pandas 转成pd.NaT。我的个人建议是转换前先做一次全量扫描把包含非法浮点值的行数统计出来然后决定清洗策略。不要在代码里写死“遇到 NaN 就置 null”因为你得先确认这些 NaN 是不是数据质量问题。5.2 日期时间类型时区、精度和isoformatArrow 的Timestamp类型在转成 Python 对象后是datetime.datetimejson.dumps默认不支持会走default兜底。我在_default_serializer里用isoformat()解决。但这里有一个更隐蔽的问题时间精度。Parquet 里的时间戳精度可以是秒、毫秒、微秒、纳秒。如果你用 pandas 读取默认会转成datetime64[ns]但isoformat()输出的是微秒精度。如果原始数据是纳秒级你输出的时候其实已经丢精度了。这在大多数业务场景里无所谓但如果你的下游在对比时间戳值时发现对不上就需要回溯到这里。另一个问题是时区。Arrow 的Timestamp可以带时区信息转成 Pythondatetime后也可能是带时区的。JSON 里你只能输出字符串所以建议约定一个统一的时区格式比如都转成 UTC 的 ISO 8601 字符串。否则同一个时间不同来源的文件可能输出2024-06-01T12:00:00和2024-06-01T20:00:0008:00两种格式下游处理起来会非常崩溃。5.3 嵌套结构Struct 和 List 的保真问题前面用to_pydict()已经解决了大部分嵌套问题但你还得面对嵌套内部的特殊类型。比如struct里套了一个timestamp或者list里套了一个decimal。这些在 JSON 里都是合法的结构但你需要在递归层面处理这些“叶子节点”。给你一个通用的递归清理函数放到代码里就能用def clean_value(value): if isinstance(value, dict): return {k: clean_value(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [clean_value(v) for v in value] if isinstance(value, bytes): return value.decode(utf-8, errorsreplace) if hasattr(value, isoformat): return value.isoformat() if isinstance(value, float) and math.isnan(value): return None return value在输出前对每个值调用clean_value()再用json.dumps序列化可以覆盖绝大多数类型问题。注意这个方法有两个额外好处一是它把Decimal保留为对象时你还需要在default兜底里处理二是它能处理嵌套结构的递归问题不需要你为每一层都写特殊逻辑。5.4 字典编码列Dictionary 转回普通值Parquet 有一种很常见的优化手段叫字典编码——某一列的值如果重复度很高Arrow 会把它们压缩成一个字典数据区只存字典索引。这样读取时你会得到DictionaryArray它的值本身是字典编码的索引但to_pydict()会自动帮你解引用到真实值。所以这一层不需要你额外处理。但有一个例外当字典编码列里存在 null 值时某些版本的 PyArrow 会把 null 和缺失值混在一起导致你在to_pydict()结果里看到None和某个特殊标记。这时候你需要手动检查一下该列的类型column.type是不是dictionary...以及它的null_count。如果是就明确清洗为 None。5.5 二进制数据Parquet 里的 bytes 怎么变成 JSON 字符串Parquet 的Binary类型在 Python 里是bytes。json.dumps不支持 bytes所以必须转成字符串。但转成什么字符串是有讲究的。如果你知道它是 UTF-8 编码的文本直接decode(utf-8)即可但如果是图片、加密串、或者别的什么直接 decode 大概率会报错或者变成一坨乱码。最稳妥的方法是 base64 编码这样原数据可以无损还原import base64 def bytes_to_base64(value: bytes) - str: return base64.b64encode(value).decode(ascii)把这种函数放进clean_value或者default_serializer里二进制字段就不会成为阻碍了。至于下游是想要可读的文本还是无损的 base64那是业务层面的取舍但至少你不会在这里挂掉。6. 大数据量下的工程处理分片、校验与断点续跑前面讲的是单文件的正确写法。但生产环境里我遇到的 Parquet 转换任务绝大多数不是“一个文件”而是一个目录下几十个甚至上百个文件。这时候如果还逐文件手动跑既不现实也容易出错。你需要一套稍微工程化的处理思路。6.1 按文件分片输出天然支持并行如果一个批次的数据是多个 Parquet 文件最简单可靠的做法是一个输入文件对应一个输出 JSONL 文件最后再决定要不要合并。这样做的最大好处是失败恢复极其简单——哪个文件挂了重新跑哪个就行不用从头再来。import glob from pathlib import Path src_dir Path(data/parquet) dst_dir Path(data/jsonl) dst_dir.mkdir(parentsTrue, exist_okTrue) for src_file in sorted(src_dir.glob(*.parquet)): dst_file dst_dir / f{src_file.stem}.jsonl if dst_file.exists(): continue # 已经处理过跳过断点续跑的关键 try: total parquet_to_jsonl(src_file, dst_file) print(f[OK] {src_file.name} - {dst_file.name} ({total} 行)) except Exception as exc: print(f[FAIL] {src_file.name}: {exc})这段代码的断点续跑能力来自if dst_file.exists(): continue。它虽然简单但在处理几百个文件时非常管用。你可以放心地让脚本跑一半就退出再启动时它会自动跳过已经完成的部分。6.2 输出完成后的校验不只是对比行数转完之后你拿什么证明这次转换是正确的行数一致只是一个必要条件不是充分条件。我建议至少做三件事一是检查每个输出文件的行数是否等于输入 Parquet 的num_rows。这一步用肉眼对比很痛苦可以在脚本里自动做src_rows pq.ParquetFile(src_file).metadata.num_rows dst_rows sum(1 for _ in open(dst_file, encodingutf-8)) assert dst_rows src_rows, f行数不匹配: {src_file} {src_rows} vs {dst_rows}二是抽检几行内容确认字段结构符合预期。尤其是有嵌套结构的文件转换后的结构应该跟你体检时看到的 schema 一致。抽检时优先选择文件尾部因为尾部数据以前经常会因为增量追加而出现类型不一致的问题。三是做一次“回读测试”用 Python 的json.loads把输出文件的每一行都解析一遍确认所有行都是合法 JSON。如果输出文件很大你不可能全量回读那就随机采样一万行做一次。这个测试能快速暴露allow_nanFalse没兜住的问题。6.3 DuckDB 路线一杯茶的时间跑完如果你只是想要“最快地完成转换”不介意牺牲一点点控制权duckdb 其实是性能怪兽。它的 SQL 语句简单到令人发指import duckdb duckdb.sql( COPY (SELECT * FROM read_parquet(data/parquet/*.parquet)) TO data/output.jsonl (FORMAT json, ARRAY false) )就这么几行它会自动处理分片、并行读取速度通常比手写的 PyArrow 循环快不少。但它有两个问题一是嵌套结构的行为跟手写方案略有差异需要你先跑个小样本验证二是如果数据里有非法浮点值或类型怪异的字段它会用一种你未必预期的方式处理而不是报错。所以在生产场景里我通常只把 duckdb 当作“快速预览”工具真正交付给下游还是用 PyArrow 手写方案。6.4 输出文件要不要压缩JSONL 转出来之后文件体积通常比 Parquet 大 3 到 10 倍。如果你的下游不在乎明文输出成.jsonl.gz往往比.jsonl更合适。实现起来只需要改一处把打开输出文件的代码换成 gzip 的上下文管理器。import gzip with gzip.open(dst_path, wt, encodingutf-8) as out_f: ...需要注意gzip 压缩是需要额外 CPU 的而且如果下游系统不支持读 gzip你就别用这个方案。但如果你要传输或归档压缩率通常能到 80% 以上非常值得。7. 从一次真实故障说起排查链路完整复盘前面讲了很多理论最后用一个真实的故障案例收尾让你看看这些知识点在实际中是怎么连起来的。那是一个线上跑了一个多月的定时任务某天突然挂掉了。报错信息看起来是TypeError: Object of type date is not JSON serializable第一眼看到这个错误我心想不就是 date 对象没处理吗加个default函数就行。结果加完再跑报错变成ValueError: Out of range float values are not JSON compliant这就说明数据里有 NaN 或 Infinity。我用allow_nanFalse把问题暴露出来后用脚本统计了 NaN 的分布发现是某一天新增的埋点字段里有一列浮点数全是空值。以前的文件里这一列从来没有出现过全空的情况。接着我去看那个字段在 Parquet 里的类型发现它是double。to_pydict()把空值转成了float(nan)而之前的文件里因为至少有一个有效值Arrow 会把它作为 null 处理整体表现是None。全空的时候Arrow 的字典编码逻辑就走了一条不同的分支结果出来的就是 Python 浮点 NaN。这个案例的核心教训是即使同一个 Parquet 文件里的同一个字段不同批次的数据也可能导致底层表示不同。所以转换脚本必须对“合法但不常见”的数据形态有兜底而不是只在测试文件上验证。排查的时候我是按这个链路走的先跑体检脚本确认当前文件的 schema 和字段类型。发现字段类型是 double查看null_count确认全列为空。用一个小脚本打印to_pydict()后该列前几行的值和类型。定位到空值被表示成nan而非None。在清洗函数里统一加math.isnan判断输出为null。整个链路耗时不到半小时但如果我对to_pydict()的行为没有基本预判可能要在报错堆栈里绕很久。这个案例也解释了我为什么反复强调转换脚本一定要把“特殊值”当成常态来处理而不是侥幸地认为“我们数据很干净”。数据是活的schema 只是它某个时刻的侧写真正流的每一批数据都可能带来惊喜。8. 最后再分享一个实用技巧如果你只记得这篇文章里的一件事我希望是这件事别让下游拿着你的 JSONL 文件去猜里面的类型。Parquet 的关键优势之一是它自带 schema而 JSONL 没有任何 schema 约束。同一列在第一行是字符串第二行可能因为缺值变成了 null下游解析时就会非常头痛。所以我在交付 JSONL 时总会在同一目录下放一个schema.json把每个字段的名字和类型列出来。它看起来只有几行{ user_id: string, event_time: timestamp, score: double, tags: arraystring }别小看这个文件。它帮我在多个项目里避免了下游的反复追问你们的event_time到底是什么格式score会不会是字符串有了这个 schema 说明所有疑问一次说清。至于转换本身我的最终建议是先把体检脚本跑一遍然后从 PyArrow 手写方案起步用小样本验证输出确认无误后再全量跑。如果你觉得每次都要写一遍太麻烦可以把parquet_to_jsonl函数保存成一个独立模块以后任何项目里直接 import 就能用。我自己就是把这套代码沉淀成了一个内部工具现在每次遇到 Parquet 转 JSONL 的需求基本都是十秒内给出答案。