
大多数 Python 数据工程师最早学的是 pandas。因为它是行业标准能用而且一直够用所以一般也没人质疑过它。Pandas 设计于 2008 年面向的是那个时代的数据问题假设每个操作都要立即返回结果假设单个 CPU 核心足够假设数据能放进内存。这些假设成立了很多年。随着 Pipeline 规模增长它们越来越站不住脚。这里说的并不是 pandas 好不好因为它很好而且我现在也在用。现在的问题是问题在于它是否还匹配你实际在跑的工作负载。对于超过约 1 GB 的文件型 ETL 来说并不是pandas 出了问题而是整个生态已经为这类工作产出了一个结构上更优越的工具——Polars。两者之间的差距不是量级上的小打小闹。Polars 为何在结构上更快谈基准测试数据之前先搞清楚原理。下图并排展示了两种执行模型。Pandas 的单线程即时执行eager executionvs. Polars 的多线程惰性执行lazy execution。Pandas 的执行模型Pandas 运行在 NumPy 之上大多数操作是单线程的。Python 的全局解释器锁GIL阻止了并行执行即便有多个线程可用。每个操作都立即执行所以无论是否需要中间结果都会被创建并存入内存。对一个 5 GB 的 DataFrame 做五步变换就会产生五份中间副本内存占用迅速叠加。Pandas 默认将字符串存储为 NumPy object 数组每个值都是一个 Python 对象指针每个唯一字符串大约占 67 字节而列式存储只需 4 字节的额外开销。100 万个 18 字符的字符串在 pandas object dtype 下约占 75 MB同样的列在 Arrow 格式下约占 22 MB。Polars 的执行模型Polars 用 Rust 编写完全运行在 GIL 之外。数据以 Apache Arrow 列式格式存储每列是一块连续的类型化内存对 CPU 缓存友好也支持 SIMD 指令。调用scan_parquet时不会读取任何数据。Polars 先构建逻辑执行计划在触碰任何字节之前完成优化谓词下推到扫描层、未使用的列在读取前裁剪、Join 顺序重排以提升效率。执行采用 morsel 驱动模式。每个 CPU 线程取一块输入用自己独立的本地状态处理最后合并结果计算过程中没有锁竞争所有核心同时运行。处理一个 10 GB Parquet 文件时pandas 读取全部 10 GB在单核上顺序处理并在途中产生中间副本。同样的 Pipeline 在 Polars 中只读取满足过滤和聚合所需的列和行组在所有核心上并行处理不会实体化任何不必要的中间结果。每次给 Pipeline 计时这种差距都能感受到pandas 已经在读取时Polars 还在规划但 Polars 先完成。基准测试下表汇总了不同规模和负载类型下的结果。三个独立基准测试分别对比了查询 Pipeline、生产 ETL 迁移和调度负载场景下 Polars 与 pandas 的表现。PDS-H 基准测试Polars 团队于 2025 年 5 月发布了更新的 PDS-H 测试结果运行环境为 AWS c7a.24xlarge 实例96 vCPU、192 GB RAM。测试对 10 GB CSV 数据集运行全部 22 条 TPC-H 衍生查询。Polars 流式处理耗时 3.89 秒pandas 2.2.3启用 PyArrow dtype耗时 365.71 秒——整条多步分析 Pipeline 下来差距达到 94 倍。在 Scale Factor 100100 GB的场景下pandas 被直接排除在外。单线程执行加上缺乏查询优化器导致在完成基准测试之前就触发了内存溢出OOM。Polars 流式处理耗时 23.94 秒。这是厂商自己跑的基准测试可以把它当作方向性参考。在普通硬件上的独立测试差距通常小一些单个操作一般在 5 到 22 倍之间。那个标题级别的倍数是查询优化器和多线程在完整 Pipeline 中叠加后才出现的。生产环境的实证荷兰出行服务商 Check Technologies 在一个 Sprint 内将全部 100 多个 Airflow DAG 从 pandas 迁移到了 Polars。驱动力不是性能基准而是最数据密集的 Pipeline 上反复出现的 OOM 错误。迁移耗时不到两周最问题严重的 DAG 速度提升了 3.3 倍几乎所有其他 DAG 提升约 2 倍云基础设施成本降低了 25%。Check 的高级数据工程师 Paul Duvenage 表示团队一旦切换到声明式表达式 API迁移过程就非常顺畅。DB Systel德国铁路子公司用 Polars 0.20 重写了一个列车调度重处理任务。该任务原本需要 96 分钟Polars 版本只需 5.5 分钟——在真实生产负载上提升了 17.5 倍而不是在合成基准上。在小数据上差距基本消失有时甚至会反转。Polars 的查询优化器存在规划开销当数据集小于几百 MB 时这个开销会超过计算节省。对于小 DataFrame 上的快速脚本pandas 写起来更快跑起来也够快。安装与运行安装只需一条命令# 创建项目并添加 Polars uv init polars-pipeline cd polars-pipeline uv add polars pyarrow # 标准安装 uv run pipeline.py无需编译无需系统依赖。Polars 以预编译 wheel 形式发布Rust 运行时已打包在内。⚠️ 开始之前有一点值得注意在 Apple SiliconM 系列 Mac上标准 polars wheel 会触发 CPU 兼容性警告并建议使用运行时兼容版本。# Apple Silicon改用这个 uv add polars[rtcompat] pyarrowLinux 和 Intel Mac 上使用标准安装即可。在 M 系列硬件上polars[rtcompat]可避免警告并确保使用适合该处理器的正确 SIMD 指令。并排对比两个库实现相同操作第一次看 Polars 代码时语法感觉很陌生。表达式 API 需要一天时间适应适应之后发现它比 pandas 的等价写法更易读而不是更难读。下面的代码执行完全相同的 ETL 变换读取 Parquet 文件、过滤行、按类别聚合、排序结果。先是 pandas 版本然后是 Polars LazyFrame 版本。# pandas 版本 importpandasaspd importtime starttime.perf_counter() dfpd.read_parquet(sales.parquet) result ( df[df[revenue] 1000] .groupby(category) .agg( total_revenue(revenue, sum), avg_price(price, mean), order_count(order_id, count), ) .sort_values(total_revenue, ascendingFalse) .reset_index() ) print(fpandas: {time.perf_counter() -start:.3f}s) print(result)# Polars 版本——带谓词下推和投影下推的惰性执行 importpolarsaspl importtime starttime.perf_counter() result ( pl.scan_parquet(sales.parquet) # 此时不读取任何数据 .filter(pl.col(revenue) 1000) # 下推到扫描层 .group_by(category) .agg( total_revenuepl.col(revenue).sum(), avg_pricepl.col(price).mean(), order_countpl.col(order_id).count(), ) .sort(total_revenue, descendingTrue) .collect() # 执行在这里发生 ) print(fPolars: {time.perf_counter() -start:.3f}s) print(result)结构上的关键差异在于scan_parquet对比read_parquet。Pandas 版本立即读取整个文件Polars 版本构建执行计划只读取满足过滤和聚合所需的行和列。对于一个 1 GB、20 列、实际只需要 3 列的文件Polars 读取的字节数可能不到 pandas 的 20%。流式处理超出内存容量的数据当数据集超过可用内存时在collect中加入enginestreaming。Polars 以称为 morsel 的批次处理数据自适应地溢出到磁盘始终不将完整数据集保留在内存中。result ( pl.scan_parquet(large_dataset/*.parquet) .filter(pl.col(status) active) .group_by(region) .agg(pl.col(amount).sum()) .sort(amount, descendingTrue) .collect(enginestreaming) # 核外执行 )如需直接写入磁盘而完全不在内存中实体化使用sink_parquet( pl.scan_parquet(raw/*.parquet) .filter(pl.col(error_code).is_null()) .sink_parquet(clean/output.parquet) # 分批流式写入磁盘 )生产环境注意事项在生产环境中显式设置线程数以避免与其他进程争抢资源importos os.environ[POLARS_MAX_THREADS] 8 # 必须在导入 polars 之前设置 importpolarsaspl # Polars 在导入时初始化线程池—— # 导入后再设置此变量无效在pyproject.toml中锁定 Polars 版本。1.x API 已稳定但小版本更新会引入新特性在边缘情况下可能改变行为。锁定版本在与生产 OS 一致的容器中测试有意识地升级。Pandas 仍然占优的场景Polars 是 1 GB 以上文件型 ETL 的更好选择下图按数据规模和负载类型梳理了决策框架。按数据规模和负载类型选择工具并明确标注了机器学习生态系统的约束。大多数机器学习库以 pandas DataFrame 作为原生输入格式。scikit-learn、statsmodels 以及很多绘图库要么明确要求 pandas要么默认如此。为了避免一次.to_pandas()调用而重写整个技术栈这笔买卖不值得做。在每一个需要给模型喂数据的 Pipeline 中实践中的做法是混合方案用 Polars 做繁重的计算工作最后一步再转换# 用 Polars 做重活 features ( pl.scan_parquet(events/*.parquet) .group_by(user_id) .agg([ pl.col(session_duration).mean().alias(avg_session), pl.col(purchase).sum().alias(total_purchases), pl.col(event_date).max().alias(last_seen), ]) .collect(enginestreaming) ) # 在边界处零拷贝转换 importsklearn Xfeatures.to_pandas() # 基于 Arrow几乎瞬间完成转换几乎瞬间完成因为 Polars 和新版 pandas 共享 Apache Arrow 内存格式——当两边都使用 Arrow 类型时不会发生数据拷贝。Pandas 3.0 带来了什么变化2026 年 1 月发布的 pandas 3.0将 PyArrow 支持的字符串设为字符串列的默认类型并将写时复制Copy-on-Write设为唯一执行模式。这些改动使字符串密集型数据集的内存占用降低了最多 70%并消除了一类静默的变异 bug。这是真实的改进。它在 I/O 和内存方面实质性地缩小了差距。但多核执行的差距依然存在——pandas 的聚合操作在任何版本中仍然是单线程、即时执行的也没有查询优化器。如果瓶颈是计算而非内存3.0 版本并没有改变这一判断。总结有三个信号说明一条 Pipeline 已经到了该迁移的时候第一在生产规模的数据上触发 OOM或者已经为了绕过内存限制加入了分块逻辑。第二本该几秒完成的任务跑了好几分钟性能分析显示 pandas 的 groupby 或 join 操作占据了主要运行时间。第三运行在一台多核机器上执行期间那些 CPU 核心基本闲着。迁移的时候可以选最慢的那条Pipeline或者最常 OOM 的那条。只用 Polars LazyFrame 重写计算密集的部分在输出端保留 pandas 边界以衔接机器学习或绘图然后在生产规模的数据上基准测试再推上线。如果在超过 1 GB 的数据集上看不到至少 3 倍的提升瓶颈可能根本不在 DataFrame 库上。Pandas 不是遗留技术它是精准的专用工具Polars 是另一种工作的精准专用工具。为每项工作选择合适的不是迁移项目是工程判断力。https://avoid.overfit.cn/post/da28f678d72c42729ccef7af469c1731作者Nevenka Lukic