前几天突然被几个同事同时找,起因是数仓那边丢过来一批Parquet文件,每个几百MB,而下游的数据消费端只认JSONL。本来以为就是读一下再写一下,结果真在Python里转换时发现坑不少:小文件用pandas一条命令能出,文件一上来内存先爆了;类型处理不好,日期时间变成乱码;还有人直接把JSON数组写进去,下游根本读不了。回头把方案重新捋了一遍,觉得这块很值得专门写一篇。它的核心不只是“Parquet转JSONL”这一步,而是围绕格式转换时的数据形态、内存控制、类型映射和上下游兼容性。如果你想找一套能直接照着改的代码和思路,这篇应该能让你少走很多弯路。
这套转换在真实业务里出现频率非常高。数据仓库喜欢用Parquet做存储和交换格式,因为它列式存储、压缩率高、适合分析型查询。但很多业务系统、日志采集管道、全文检索平台,反而更喜欢处理JSONL,因为JSONL每一行就是一个完整对象,没有外层包裹,天然支持按行读取、按行回放、按行消费。两边格式目标不同,中间就需要一座桥。我见过不少人直接拿别人博客里的三行代码去转,结果要么损失精度,要么文件一大多进程被杀,要么嵌套字段被拆得稀碎。这篇文章会把方案选型、类型映射、内存控制和大文件处理完整拆开讲,每一步都能直接复现。
1. 为什么非要把Parquet倒腾成JSONL
1.1 先搞清楚两种格式到底差在哪
Parquet最核心的特点是列式存储。它把同一列的数据连续存放,这样在做聚合、过滤、扫描某些字段时,可以只读取相关列,而且同列数据类型一致,压缩算法能拿到更好的压缩比。举个例子,一张表如果有100个字段,但分析任务只用到其中3个,列式存储完全可以跳过另外97个字段的IO。这也是数仓和分析引擎偏爱Parquet的根本原因。
JSONL又叫JSON Lines,本质是把多个JSON对象用换行符拼接在一起,每一行单独解析。它最大的优点是“按行可用”:不需要一次性加载整个文件,读一行就能处理一行,遇到坏行也只会影响当前行。对于日志采集、消息推送、数据导入这类逐条消费的场景来说,这种格式极其友好。缺点是它没有schema约束,也没有压缩和统计信息,每个JSON对象都有重复的键名,文件体积通常比Parquet大不少,如果字段很多,写出来的文件会非常冗余。
所以两种格式解决的是不同问题:Parquet偏向存储和分析,JSONL偏向传输和逐行消费。把Parquet转成JSONL,不是简单换一层皮,而是要读懂Parquet的类型体系,再变成JSON能够表达的字符串、数字、布尔、对象和数组。真正容易翻车的往往就是类型转换,而不是文件读写本身。
1.2 哪些场景最容易碰到这种转换
我盘点了一下手头接到过的需求,出现频率最高的是下面三类场景。
第一类是数仓数据需要被外部系统消费。数据团队习惯把明细表导出成Parquet放在对象存储上,但下游BI报表、算法平台或者第三方服务可能不认Parquet,只接受JSONL、CSV这类更通用的行式格式。第二类是数据管道中转。有些公司在做ETL时会把中间过程统一转换成JSONL下发到消息队列或者日志平台,方便后续用Flink、Spark Streaming这类引擎逐条消费,减少对源表的重复扫描。第三类是离线数据需要临时分析。非技术同事拿到Parquet文件后打不开,转成JSONL后可以直接用文本编辑器查看,或者导入到某些只支持JSON导入的数据库里。
不管哪种场景,转换的正确性都比速度重要。这里有几个容易踩的坑:Parquet里的时间戳通常是微秒精度,JSON标准里没有原生时间类型,需要约定好序列化成ISO 8601字符串;Parquet里的Binary字段更麻烦,直接json.dumps会报错,必须先做编码转换;还有作为关联键的大整数,比如雪花ID,如果目标解析端用JavaScript来读,超过2^53就会丢失精度,需要主动处理成字符串。这些都不是靠“pandas一行转换”能完美解决的,必须在动手转换前考虑清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具选型:为什么推荐PyArrow打底而不是无脑Pandas
2.1 PyArrow是读取Parquet真正的主角
很多人提到用Python处理Parquet,第一反应是pandas.read_parquet,但pandas本身没有能力直接解析Parquet,它底层依赖的还是PyArrow。pandas.read_parquet内部调用的是pyarrow.parquet模块的读取能力,再把结果从Arrow Table转换成DataFrame。这意味着绕了一圈,最后还是回到PyArrow上。
直接使用PyArrow有几个明显优势。第一是内存更可控,PyArrow可以按行组或按固定行数迭代读取RecordBatch,不需要把整个Parquet文件一次性灌入内存;Pandas则倾向于把全部数据加载成DataFrame,文件一大就容易内存暴涨。第二是类型保留更完整,PyArrow能感知到Parquet元数据里的时间戳单位、时区信息、Decimal精度、嵌套的Struct和List类型,而Pandas在转换过程中可能会把一些列变成object类型,细节丢了一半。第三是解析开销更低,PyArrow读取后得到的是底层C++数据结构,只有当你显式调用to_pylist或者to_pandas时,数据才会被转成Python对象。
所以在转换场景里,我更推荐用PyArrow直接读取,再配合标准库json来逐行输出。PyArrow负责“读懂Parquet”,json负责“写出JSONL”,两者职责清晰,不需要引入Pandas这个重型中间层。
2.2 极简的Pandas什么时候够用
也不是说Pandas完全不能用。如果文件不大,比如只有几百MB,行数在百万以内,字段类型基本都是整数、小数、字符串和布尔,那么用Pandas一行读、一行写是非常高效的做法,代码量最少,维护成本也低。
python复制import pandas as pd
df = pd.read_parquet("input.parquet")
df.to_json("output.jsonl", orient="records", lines=True, force_ascii=False)
这里的三个参数是重点。orient="records"表示每个DataFrame行输出成一个JSON对象;lines=True表示不是输出成一个大型JSON数组,而是每行一个JSON对象;force_ascii=False表示保留中文等非ASCII字符,而不是转成\uXXXX序列。这三个参数缺一个,或者参数值不对,写出来的东西就不是标准JSONL。
但Pandas版本有它的边界。如果你的Parquet里包含很多嵌套结构、二进制、高精度Decimal,或者文件根本放不进内存,那最好还是老老实实回到PyArrow方案。Pandas在类型转换上还会自作主张,比如把时间戳列转成pandas自己的datetime64类型,换到目标系统时如果时区处理不一致,很容易差出几个小时甚至直接报时区不可比对的错。
2.3 动手前的环境准备
开始写脚本之前,先确认你的Python环境里是否已经安装了pyarrow和pandas。我自己常用Python 3.10以上的版本,因为新版PyArrow对类型系统和Parquet新版特性的支持更完整。安装命令很简单:
bash复制python -m pip install -U pyarrow pandas
如果你所在的环境里既有Python 2又有Python 3,或者系统里同时存在多个虚拟环境,建议先用python --version确认当前解释器路径,再用python -m pip而不是pip,这样能避免包装错环境。装完后执行下面的命令,能正常打印版本号就说明环境通了。
python复制import pyarrow
import pyarrow.parquet as pq
print(pyarrow.__version__)
另外想提醒一下Windows用户。在cmd或PowerShell里跑脚本前,一定要进到你的项目虚拟环境,或者至少在正确的Python环境中运行。很多报错ModuleNotFoundError并不是代码问题,而是包的安装位置和解释器不匹配,这种事我在本地开发时遇到过不止一次,先检查env再查代码,效率会高很多。
3. Parquet转JSONL最容易翻车的四个细节
3.1 读取Parquet别一上来就to_pandas
用PyArrow读取Parquet最基础的方式是pq.read_table,它会把整个文件读成一个Arrow Table。这个操作本身不会把所有行立刻转成Python对象,但Table仍然会占用大量内存。如果文件是几个GB,光read_table就有可能在内存紧张的生产机上被杀掉。
更好的方式是用pq.ParquetFile打开文件,再通过iter_batches按批迭代。Parquet内部本质上是按行组和列块存储的,ParquetFile能够识别这些行组边界。iter_batches返回的是一批批RecordBatch,每个RecordBatch的规模由batch_size控制。处理一批、释放一批,内存水位就能保持得非常平稳。
我自己习惯的写法是:
python复制import pyarrow.parquet as pq
import json
input_path = "input.parquet"
output_path = "output.jsonl"
parquet_file = pq.ParquetFile(input_path)
with open(output_path, "w", encoding="utf-8") as f:
for batch in parquet_file.iter_batches(batch_size=5000):
for row in batch.to_pylist():
f.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
这里batch_size=5000表示每批处理5000行。对于一个包含几十列、单行数据比较大的文件来说,5000行转换出来的Python对象不会太多,内存占用很稳定。如果你的Parquet是很多个小的row group组成的,batch_size通常不会小于单个row group的行数,PyArrow会在行组边界自动做切分。
3.2 Python字典与JSON序列化之间的类型暗礁
这是整个转换过程里最容易被忽视的一环。Parquet支持的类型比JSON基础类型丰富得多,如果把Parquet记录转成一个Python字典,字典里的值可能包含bytes、datetime.date、datetime.datetime、Decimal,甚至numpy的int64和float64。标准库json.dumps并不能处理所有这些类型,默认会抛TypeError。
解决办法是给json.dumps传一个default函数,凡是Json没法直接识别的类型都交给这个函数处理。我平时给自己项目里配置的default函数大致长这样:
python复制import json
from datetime import date, datetime, timezone
from decimal import Decimal
def json_default(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, bytes):
try:
return obj.decode("utf-8")
except UnicodeDecodeError:
return obj.hex()
raise TypeError(f"type {type(obj)} not serializable")
把时间类型转成isoformat字符串,能保证大多数平台都能解析;Decimal转成字符串是为了防止精度丢失,毕竟Python的Decimal如果直接转float再输出,小数位数可能对不上;bytes字段需要谨慎,如果本来就包含UTF-8文本,直接decode还行,如果是不可读的二进制内容,建议用hex或者base64编码,并且要在下游文档里写清楚。
更麻烦的是时区问题。Arrow的时间戳如果带了时区信息,to_pylist之后会变成带tzinfo的datetime对象,isoformat之后会带着+00:00或+08:00这样的后缀。部分下游JSON解析器无法处理时区偏移,可能报字符串格式错误,也可能解析成错误的本地时间。遇到这种情况,建议在转换前统一把时间字段处理成UTC无时区格式,或者全部转成某个固定时区的字符串,前后端保持一致。
3.3 JSONL输出的编码、换行与字段顺序
JSONL看起来就是普通文本,但它对细节要求不少。首先是编码,如果数据里包含中文,写文件时最好显式指定encoding="utf-8",并且把ensure_ascii设成False。如果不这样做,中文会被转成\u4e2d\u6587这种形式,文件能读,但可读性和排查性都会变差。
其次,json.dumps默认生成的JSON对象里,键和值之间会带空格,比如{"name": "alice"}。如果你对文件体积很敏感,可以用separators参数压缩:
python复制line = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
输出会变成{"name":"alice"},体积小一点。对单条数据来说这点差异可以忽略,但几千万行的JSONL文件,压缩效果还是比较明显的。不过是否压缩完全取决于下游需求,有些系统示例文件里带空格,有些不带,最好先看看下游是否能正常解析。
字段顺序取决于Parquet文件的schema顺序,batch.to_pylist返回的字段顺序和Parquet列顺序一致。只要Parquet文件在生成时字段顺序是稳定的,输出JSONL的字段顺序也就稳定。这里要小心,不要贪图方便对字典做sort_keys=True排序,排序后输出虽然稳定,但如果和上游字段顺序不一致,下游对列位置敏感的程序可能会错位。
最后注意换行。每写完一条记录,一定要用"\n"结尾。标准JSONL要求每条记录由换行符分隔,但不要在写入过程中额外输出空行。空行会干扰某些按行读取且不允许空行的程序。上面那种for循环逐行写入,天然不会产生额外空行,保持原样就好。
3.4 大文件内存控制的正确打开方式
如果只是处理小文件,怎么折腾都行。真正考验方案的是大文件。我之前遇到过一个Parquet,逻辑行数约2000万行,单文件3.5GB。如果用pandas.read_parquet直接读,内存峰值轻轻松松超过12GB,普通16GB内存的机器跑起来非常危险。但改成ParquetFile.iter_batches逐批处理之后,内存占用基本稳定在1GB左右,因为每一批处理完,前一批的Python对象就失去了引用,会被垃圾回收器回收。
控制内存的要点有三条。第一,一定不要反复把整个Table转换成Pandas DataFrame然后再转回列表,那样内存会在某一瞬间双重占用。第二,在for循环里处理完每个batch后,尽量不要把结果再append到一个总list里,否则又会把所有数据堆回内存。正确的做法是边循环边写入输出文件。第三,如果输出文件本身也要压缩,尽量用gzip或zstd去包装输出文件句柄,写的时候同步压,而不是先写出完整JSONL再二次压缩。
如果单文件实在太大,还可以考虑按行组拆分成多个输出文件,比如每个row group输出一个独立的JSONL文件。ParquetFile.num_row_groups可以告诉你文件里有多少个行组,遍历每个行组的行后写入以块编号命名的文件。这样不仅降低单文件体积,也方便之后做分布式处理。
4. 一套可以直接跑的完整转换脚本
4.1 生成一份带典型数据类型的Parquet测试文件
为了说明完整过程,我先生成一个测试用Parquet文件。里面故意放了几种高频类型:整数、布尔、日期、时间戳、字符串列表、Struct嵌套和Binary字段。如果你自己已经有Parquet文件,这段可以跳过,直接读你自己的文件就行。
python复制import pyarrow as pa
import pyarrow.parquet as pq
import datetime
data = {
"id": pa.array([1001, 1002, 1003]),
"name": pa.array(["alice", "bob", "张三"]),
"score": pa.array([95.5, 87.3, 92.0]),
"active": pa.array([True, False, True]),
"register_date": pa.array([datetime.date(2023, 5, 1), datetime.date(2023, 6, 18), datetime.date(2024, 1, 7)]),
"last_login": pa.array(
[
datetime.datetime(2024, 2, 1, 10, 30, 0),
datetime.datetime(2024, 2, 2, 22, 15, 30),
datetime.datetime(2024, 2, 3, 8, 0, 0),
],
type=pa.timestamp("us"),
),
"tags": pa.array([["vip", "老客"], ["new"], ["vip", "潜力"]]),
"profile": pa.array(
[
{"city": "上海", "level": 2},
{"city": "北京", "level": 5},
{"city": "广州", "level": 1},
],
type=pa.struct([("city", pa.string()), ("level", pa.int32())]),
),
"raw_data": pa.array([b"abc", b"\x00\x01", b"xyz"]),
}
table = pa.table(data)
pq.write_table(table, "demo.parquet")
写完后可以用pq.read_schema看一眼字段。这里注意,register_date是date类型,last_login是微秒精度的timestamp,tags是List
4.2 推荐的主力转换代码
下面这段是我实际项目中常用的通用脚本,兼容上面的所有类型。它在读取时用ParquetFile,避免一次性加载整个文件;在序列化时用自定义json_default,把日期、时间戳、Decimal、二进制按约定处理;在写文件时显式指定UTF-8,并通过flush确保数据及时落盘。
python复制import pyarrow.parquet as pq
import json
from datetime import date, datetime
from decimal import Decimal
INPUT_PATH = "demo.parquet"
OUTPUT_PATH = "demo.jsonl"
BATCH_SIZE = 5000
def json_default(obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, bytes):
return obj.decode("utf-8", errors="replace")
raise TypeError(f"type {type(obj)} not serializable")
def convert_parquet_to_jsonl(input_path, output_path, batch_size=5000):
parquet_file = pq.ParquetFile(input_path)
total_rows = parquet_file.metadata.num_rows
written_rows = 0
with open(output_path, "w", encoding="utf-8", newline="") as f:
for batch in parquet_file.iter_batches(batch_size=batch_size):
for row in batch.to_pylist():
f.write(json.dumps(row, ensure_ascii=False, default=json_default))
f.write("\n")
written_rows += 1
return total_rows, written_rows
if __name__ == "__main__":
total, written = convert_parquet_to_jsonl(INPUT_PATH, OUTPUT_PATH)
print(f"total rows: {total}")
print(f"written rows: {written}")
这段脚本的运行逻辑很直接:parquet_file.metadata.num_rows先拿到总行数,方便之后核对;iter_batches按批次读入数据;每批数据通过to_pylist转成Python原生对象列表;再逐条json.dumps成字符串并写入文件。这样写有两个优点,一是内存稳定,二是如果有某条数据序列化失败,except的位置会很明确,不会把整批数据全浪费掉。
如果你处理的是比较大的生产文件,建议在循环里加一个进度提示,每写满10万行打印一次当前进度。我第一次处理2000万行文件时没加日志,跑了20多分钟界面一动不动,心里特别没底,加了日志之后至少知道脚本是卡住还是在正常推进。
4.3 怎么验证输出结果是标准JSONL
转换完成不代表万事大吉,还需要从三个层面验证。第一是行数,JSONL的行数应当和Parquet的逻辑行数相等。第二是每行都能被json.loads正常解析,验证时不引入大对象,逐行解析后直接丢弃,避免内存占用。第三是抽查字段,尤其是时间字段、嵌套字段和二进制字段,看看格式是否符合下游预期。
下面是我经常拿来快速验收的脚本:
python复制import json
count = 0
with open("demo.jsonl", "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
obj = json.loads(line)
count += 1
print(f"valid jsonl rows: {count}")
如果行数对不上,常见原因有两种。一是写入时不小心在某个逻辑内多写了空行,导致非JSON行混入;二是源Parquet某些行组包含嵌套列,但序列化过程里因为异常漏写了行。把count和parquet_file.metadata.num_rows做对比,能迅速判断是那一端出了问题。
5. 报错排查:从ModuleNotFoundError到内存打满
5.1 环境与依赖类错误
最常见的是ModuleNotFoundError: No module named 'pyarrow'。这个报错的原因基本离不开三种:包没装、装到了别的python环境、pip和当前解释器匹配不上。我的建议是优先用python -m pip安装而不是pip install。还在用VSCode写Python的话,打开右下角解释器,确认当前选中的虚拟环境和跑脚本用的是同一个环境,这道检查能解决80%以上的ModuleNotFoundError。
有时候还会遇到pyarrow版本过低的情况,特征是有一些高端API不存在,比如ParquetFile.iter_batches在旧版本里没有batch_size参数。这种问题最简单的方法是升级:
bash复制python -m pip install -U pyarrow
升级后如果代码还报错,看一下是不是本地同时存在多个版本冲突。可以创建一个干净的虚拟环境,重新安装依赖,跑一遍基础命令,把环境变量的问题排除掉。
5.2 文件本身不是Parquet
有次同事报了一个看起来很奇怪的问题,代码读文件时报错说Parquet magic bytes not found in footer。我拿到文件一看,其实是一个CSV文件改名成了.parquet。PyArrow读取Parquet时会校验文件末尾的PAR1标记,只有真正的Parquet文件才能通过。
遇到这类报错,先不用怀疑代码,尝试用file命令或者直接查看文件的前几个字节。Parquet文件一般开头会是PAR1四个字节,这也是Parquet格式的魔力字符串。如果开头不是这个,那说明源文件生成有问题,或者文件在中途传输出错。这个校验点也可以写进程序里,读文件前先抓取前4个字节做一次快检:
python复制with open("demo.parquet", "rb") as f:
head = f.read(4)
if head != b"PAR1":
raise ValueError("not a valid parquet file")
这种方法虽然不一定覆盖所有情况,但能拦截大部分明显错误。
5.3 转换到一半内存涨满或进程被杀
如果你用了parquet_file.iter_batches,但内存还是一路涨,那先从自己的代码找原因。看是不是在循环体内把所有的batch都append到了一个列表里,或者把每条转换结果都塞进了一个大字符串再一次性写入。出这种问题通常不是工具的问题,而是写法上没遵守“边读边写”的基本原则。
另一种可能是Parquet文件本身单行数据特别大,比如某些行包含超长的嵌套日志或大文本字段,一批5000行里可能有一半都是超大字段,内存自然控制不住。这种情况可以把batch_size调小到500或者200。如果单行数据都会让内存吃紧,那不是换工具能解决的,需要做行内字段裁剪,比如先过滤掉某些超大列,只转换业务真正需要的字段。Parquet是列式存储,读取时可以只select指定的列,这本身就能省下大量内存。
python复制parquet_file.iter_batches(
batch_size=5000,
columns=["id", "name", "score", "active", "register_date"],
)
5.4 序列化相关的TypeError
很多人在for循环里写json.dumps(row)时遇到TypeError: Object of type Timestamp is not JSON serializable。这是因为to_pylist返回的timestamp列是Python datetime对象,而json.dumps不认识它。只要把default参数加上,并且函数里处理datetime和date,这类报错就会消失。
还有另一种情况是数据类型是binary,直接报Bytes is not JSON serializable。这时需要决定是转成字符串还是base64。如果原始二进制内容是图片或者加密数据,建议转base64,因为JSON解析端拿到base64至少能做还原;如果只是文本被错误地存成了Binary类型,直接用UTF-8解码即可。编码策略会影响下游使用成本,尽量在转换开始前就定下来。
6. 给生产环境用前,这几个优化点值得先做
6.1 先看schema再做转换
有些Parquet文件字段很多,但下游可能只需要其中一部分。如果无脑把所有列都转换出来,不仅输出文件体积大,转换耗时也会成倍增加。PyArrow读取时支持columns参数,只看你需要的列即可。动手转换前,先执行下面这段命令把schema打出来,心里有数之后再决定要不要只取部分列。
python复制import pyarrow.parquet as pq
parquet_file = pq.ParquetFile("demo.parquet")
print(parquet_file.schema_arrow)
schema信息里也藏着隐患,比如同一个字段在不同批次文件里精度不一样,或者时间戳单位不一样,早发现早处理,比转换到一半报错要好得多。我在做政府或银行项目时,经常遇到上游Parquet的schema不那么规范,不同目录下同名文件字段类型有细微差异。先看schema能省下很多反复排查的时间。
6.2 多文件并行处理与断点续转
如果目标目录里有几十个Parquet文件,逐个串行转换可能太慢。Python处理这类场景时,可以先扫描目录获得文件列表,再用concurrent.futures.ProcessPoolExecutor按文件并行转换。每个文件独立处理,输出对应一个JSONL文件,并行度建议等于CPU物理核数的一半左右,避免把磁盘IO直接打满。
对于超大文件,断点续转也很关键。一旦程序跑到一半崩溃,重新开始整个文件的转换成本太高。我常用的思路是转换前先记录Parquet总行数,转换过程中每处理1000行就把“当前文件、当前行号”写入一个状态文件。下次启动时读取状态文件,用row_group为单位跳过已完成部分。这个改造工作量不大,但在生产任务里价值很高。
6.3 输出文件要不要压缩和切分
JSONL是纯文本,压缩率通常很高。如果你要在对象存储落盘,建议输出时直接打包成gzip或者zstd格式,能节省不少存储成本。Python侧简单处理就是把open函数替换成gzip.open:
python复制import gzip
with gzip.open("output.jsonl.gz", "wt", encoding="utf-8") as f:
# 写入逻辑一样
pass
有一个细节要注意:如果下游要求文件名带.jsonl.gz后缀,压缩完可能还需要把行尾换行符保持为\n。我遇到过Windows环境下进行文本写入时,因为open没有带newline=""而自动把\n替换成\r\n,导致下游按行读取时把\r也带进JSON内容里。写JSONL文件时建议统一加newline="",保证跨平台输出一致。
至于文件切分,取决于下游平台限制。有些日志平台要求单文件不超过1GB,有些要求行数不能超过某个上限。切分逻辑最好写在转换脚本里,而不是事后用split之类命令处理,因为按行切分JSONL理论上没问题,但从Parquet列存处理完直接分片效率更高。让PyArrow按Parquet文件行组自然切分就是一个很省力的思路。
我在实际项目里最常用的工作流是:先用ParquetFile读取元信息和schema,确认数据特征后再跑转换。普通业务数据用迭代批量写入就能满足要求;带复杂时间、Decimal、二进制字段的数据必须写自定义default逻辑;上游是大宽表时先用columns参数裁剪列。这一套组合下来,几次大批量转换都没再出问题。每次跑完都留一份样例JSONL文件给下游团队快速验证,也能把很多字段误解消除在正式交付之前。
