围绕 Polars 最常见的说法是"比 Pandas 快 10~30 倍"。这个数字在官方 benchmark 和社区博客里反复出现,但对于一线业务开发者,真正需要回答的问题有三个:在我自己的数据规模上到底快多少?优势集中在哪些操作?迁移的工程代价值不值?
本文用一份千万行真实业务数据,在严格控制变量的条件下实测载入、过滤、分组聚合、Join、排序五大核心操作,并给出迁移决策框架。
一、两位选手的架构差异
性能差距的根源不在代码层面,而在底层架构选型。
| 维度 | Pandas 2.2 | Polars 0.20 |
|---|---|---|
| 底层实现 | C + Cython | Rust |
| 执行模型 | 单线程(GIL 限制) | 多线程并行(Rayon 调度器) |
| 内存格式 | NumPy ndarray | Apache Arrow(列式、零拷贝) |
| 执行模式 | Eager(立即执行) | Eager + Lazy(惰性查询优化) |
| Lazy 优化能力 | 无 | 谓词下推、投影裁剪、并行哈希 |
三句话概括差异:Pandas 的每一步操作是单线程立即执行,Polars 是多线程并行执行;Pandas 读完 CSV 还要做一次内存格式转换,Polars 基于 Arrow 零拷贝直接可用;Polars 的 Lazy API 能在执行前看到完整查询计划,做全局优化。这些架构差异决定了后面的实测数字。
二、测试环境与数据
硬件: 8 核 16G 服务器 Python: 3.12.1 Pandas: 2.2.0 Polars: 0.20.15 数据量: 1000 万行 × 12 列(CSV 文件 567 MB)数据生成
import pandas as pd import numpy as np import polars as pl import time np.random.seed(42) N = 10_000_000 df_source = pd.DataFrame({ 'order_id': range(N), 'user_id': np.random.randint(1, 500_000, N), 'product_id': np.random.randint(1, 10_000, N), 'amount': np.random.uniform(10, 5000, N).round(2), 'quantity': np.random.randint(1, 50, N), 'region': np.random.choice(['华东', '华南', '华北', '西南', '东北'], N), 'category': np.random.choice(['电子', '服装', '食品', '家居', '图书'], N), 'status': np.random.choice(['paid', 'pending', 'refunded', 'shipped'], N), 'channel': np.random.choice(['app', 'web', 'mini_program'], N), 'order_date': pd.date_range('2023-01-01', periods=N, freq='s'), }) df_source.to_csv('orders_10m.csv', index=False)计时工具
def benchmark(func, *args, repeat=3, **kwargs): """执行多次取最优值,减少系统调度波动""" times = [] for _ in range(repeat): start = time.perf_counter() result = func(*args, **kwargs) times.append(time.perf_counter() - start) return min(times), result三、五大核心操作实测
3.1 CSV 载入
# Pandas def pandas_read(): return pd.read_csv('orders_10m.csv', dtype={'region': 'category', 'category': 'category', 'status': 'category'}, parse_dates=['order_date']) # Polars(默认多线程解析) def polars_read(): return pl.read_csv('orders_10m.csv', schema_overrides={'order_date': pl.Datetime}, try_parse_dates=True) p_time, _ = benchmark(pandas_read) pl_time, _ = benchmark(polars_read) print(f"Pandas: {p_time:.2f}s | Polars: {pl_time:.2f}s | 加速比: {p_time/pl_time:.1f}x")实测结果:
| 库 | 耗时 | 内存峰值 |
|---|---|---|
| Pandas | 12.40s | 1.8 GB |
| Polars | 2.10s | 0.9 GB |
差距的原因是双重的:Polars 用多线程并行解析 CSV 文件(8 核同时读取不同 chunk),且基于 Arrow 列式格式,解析完成后无需从 NumPy 数组做内存转换。Pandas 单线程读取完后,还要将**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">object</font>**列逐个转换为**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">category</font>**,额外消耗了 6~8 秒。
3.2 数据过滤
# Pandas:筛选金额 >1000 且华东地区的已付款订单 def pandas_filter(df): return df[(df['amount'] > 1000) & (df['region'] == '华东') & (df['status'] == 'paid')] # Polars Eager def polars_filter(df): return df.filter( (pl.col('amount') > 1000) & (pl.col('region') == '华东') & (pl.col('status') == 'paid')) # Polars Lazy(惰性执行,可优化查询计划) def polars_lazy_filter(lf): return lf.filter( (pl.col('amount') > 1000) & (pl.col('region') == '华东') & (pl.col('status') == 'paid')).collect()实测结果:
| 方案 | 耗时 |
|---|---|
| Pandas | 0.38s |
| Polars Eager | 0.09s |
| Polars Lazy | 0.07s |
过滤是纯 CPU 操作,将千万行数据均分到 8 个线程并行扫描,Pandas 单线程遍历需要 0.38 秒,Polars 8 核并行降到 0.09 秒。Lazy 模式还能在执行前发现可以提前裁剪的列(这里只需**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">amount</font>**、**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">region</font>**、**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">status</font>**三列),减少内存读取量,再快 20%。
3.3 分组聚合
这是日常分析中最频繁、也最耗时的操作。
# Pandas def pandas_groupby(df): return df.groupby(['region', 'category']).agg( total_amount=('amount', 'sum'), avg_amount=('amount', 'mean'), max_amount=('amount', 'max'), order_count=('order_id', 'count'), unique_users=('user_id', 'nunique'), ).reset_index() # Polars def polars_groupby(df): return df.group_by(['region', 'category']).agg([ pl.col('amount').sum().alias('total_amount'), pl.col('amount').mean().alias('avg_amount'), pl.col('amount').max().alias('max_amount'), pl.col('order_id').count().alias('order_count'), pl.col('user_id').n_unique().alias('unique_users'), ])实测结果:
| 方案 | 耗时 |
|---|---|
| Pandas | 1.42s |
| Polars Eager | 0.31s |
| Polars Lazy | 0.22s |
分组聚合是 Polars 优势最突出的场景。Pandas 单线程遍历千万行做 hash 分桶,耗时 1.4 秒。Polars 使用并行哈希聚合算法——将数据按 hash 分片到 8 个线程,每个线程独立构建局部哈希表,最后合并结果。Lazy 模式还能合并多个聚合操作到单次遍历中。
3.4 Join 合并
# 右表:产品维度表 1 万行 products = pd.DataFrame({ 'product_id': range(1, 10_001), 'product_name': [f'商品_{i}' for i in range(1, 10_001)], 'brand': np.random.choice(['品牌A', '品牌B', '品牌C'], 10000), }) products.to_csv('products.csv', index=False) # Pandas def pandas_join(orders, products): return pd.merge(orders, products, on='product_id', how='left') # Polars def polars_join(orders_pl, products_pl): return orders_pl.join(products_pl, on='product_id', how='left')实测结果:
| 方案 | 耗时 |
|---|---|
| Pandas | 3.85s |
| Polars | 0.52s |
Join 是 Pandas 的传统痛点。**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">pd.merge</font>**在千万级数据上做哈希连接时,单线程构建哈希表 + 单线程探测匹配,耗时接近 4 秒。Polars 使用并行哈希连接——将左表按**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">product_id</font>**的 hash 值分片,每个线程处理一片,与右表的哈希表做局部匹配后合并。
3.5 排序
# Pandas def pandas_sort(df): return df.sort_values(['region', 'amount'], ascending=[True, False]) # Polars def polars_sort(df): return df.sort(['region', 'amount'], descending=[False, True])实测结果:
| 方案 | 耗时 |
|---|---|
| Pandas | 4.67s |
| Polars | 0.85s |
排序是千万行数据上的性能重灾区。Pandas 依赖 NumPy 的单线程快速排序,在多列排序场景下需要多次分区遍历。Polars 使用并行归并排序——将数据分片排序后多路归并。
四、汇总:全景性能对比
| 操作 | Pandas | Polars Eager | Polars Lazy | 加速比 |
|---|---|---|---|---|
| CSV 载入 | 12.40s | 2.10s | — | 5.9x |
| 过滤 | 0.38s | 0.09s | 0.07s | 5.4x |
| 分组聚合 | 1.42s | 0.31s | 0.22s | 6.5x |
| Join 合并 | 3.85s | 0.52s | — | 7.4x |
| 排序 | 4.67s | 0.85s | — | 5.5x |
| 全链路 | 22.72s | 3.87s | — | 5.9x |
核心结论:千万级数据规模下,Polars 在所有核心操作上均有 5~7 倍的加速。优势集中在三类操作——Join(并行哈希)、分组聚合(并行哈希分桶)、排序(并行归并)。数据量越大,并行收益越显著。
五、公平修正:Pandas + PyArrow 后端
Pandas 2.0 引入了 PyArrow 后端,部分缩小了差距。补上这组数据:
def pandas_arrow_read(): return pd.read_csv('orders_10m.csv', dtype_backend='pyarrow', engine='pyarrow', parse_dates=['order_date']) def pandas_arrow_groupby(df): return df.groupby(['region', 'category']).agg( total_amount=('amount', 'sum'), avg_amount=('amount', 'mean'), order_count=('order_id', 'count'), ).reset_index()| 操作 | Pandas 原生 | Pandas + PyArrow | Polars |
|---|---|---|---|
| CSV 载入 | 12.40s | 3.21s | 2.10s |
| 分组聚合 | 1.42s | 0.89s | 0.31s |
| Join 合并 | 3.85s | 1.95s | 0.52s |
PyArrow 后端让 Pandas 在 CSV 载入上追近 Polars(差距从 6 倍缩小到 1.5 倍),但在分组聚合和 Join 上仍有 3~4 倍差距。单线程架构的计算瓶颈无法被内存格式优化弥补。
六、迁移成本评估
性能数字只是决策的一个维度。迁移到 Polars 意味着 API 重写和生态适配。
6.1 API 差异核心对照
| 操作 | Pandas | Polars |
|---|---|---|
| 新增列 | **<font style="color:rgb(0, 206, 185);">df['c'] = df['a'] + df['b']</font>** | **<font style="color:rgb(0, 206, 185);">df.with_columns((pl.col('a') + pl.col('b')).alias('c'))</font>** |
| 条件赋值 | **<font style="color:rgb(0, 206, 185);">np.where(df['a']>0, 'Y', 'N')</font>** | **<font style="color:rgb(0, 206, 185);">pl.when(pl.col('a')>0).then('Y').otherwise('N')</font>** |
| 分组聚合 | **<font style="color:rgb(0, 206, 185);">df.groupby('a').agg({'b':'sum'})</font>** | **<font style="color:rgb(0, 206, 185);">df.group_by('a').agg(pl.col('b').sum())</font>** |
| Merge | **<font style="color:rgb(0, 206, 185);">pd.merge(df1, df2, on='key')</font>** | **<font style="color:rgb(0, 206, 185);">df1.join(df2, on='key')</font>** |
| 链式空值填充 | **<font style="color:rgb(0, 206, 185);">df['a'].fillna(0)</font>** | **<font style="color:rgb(0, 206, 185);">df.with_columns(pl.col('a').fill_null(0))</font>** |
6.2 迁移决策矩阵
| 条件 | 建议 |
|---|---|
| 数据量 < 100 万行 | Pandas 够用,迁移收益有限 |
| 数据量 100 万~1000 万行 | 推荐迁移,单操作快 3~5 倍 |
| 数据量 > 1000 万行 | 必须迁移,Pandas 已无法胜任 |
| Join / GroupBy 是主要瓶颈 | 优先迁移这两类操作 |
| 重度依赖 scikit-learn / statsmodels | 渐进迁移,分析阶段用 Polars,ML 阶段**<font style="color:rgb(0, 206, 185);">to_pandas()</font>**转换 |
| 存量 Pandas 代码 > 5000 行 | 新模块用 Polars,旧模块按需重构 |
6.3 渐进迁移策略
# 核心思路:IO 和清洗用 Polars,ML 训练转回 Pandas pl_df = pl.read_csv('orders_10m.csv', try_parse_dates=True) pl_result = (pl_df.lazy() .filter(pl.col('amount') > 100) .with_columns([ (pl.col('amount') * pl.col('quantity')).alias('total'), pl.col('order_date').dt.month().alias('month'), ]) .group_by(['region', 'month']) .agg([ pl.col('total').sum().alias('revenue'), pl.col('order_id').count().alias('orders'), ]) .collect() ) # 需要 scikit-learn 时,零拷贝转回 Pandas pd_result = pl_result.to_pandas()**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">to_pandas()</font>**在底层使用 Arrow 零拷贝机制,转换耗时在毫秒级,不构成性能瓶颈。
七、完整数据链路的性能优化
在企业场景中,性能瓶颈不仅在分析阶段,也在数据采集阶段。原始数据通过 API 或爬虫从外部获取时,采集效率直接影响分析的时效性。
import requests import polars as pl from io import StringIO # 通过亿牛云 API 代理获取出口 IP,避免单 IP 被限流 api_url = "http://ip.16yun.cn:817/myip/pl/<ORDER_ID>/?s=<SIGN>&u=<USER>&format=json" proxy_info = requests.get(api_url, timeout=10).json()[0] proxy = f"http://{proxy_info['ip']}:{proxy_info['port']}" # 采集数据(返回 403 检查白名单,返回 429 降低频率) resp = requests.get("https://api.example.com/export", proxies={"http": proxy, "https": proxy}, timeout=30) # 直接从内存字符串载入 Polars,跳过文件落盘 df = pl.read_csv(StringIO(resp.text), try_parse_dates=True) # Polars Lazy 链式分析 result = (df.lazy() .filter(pl.col('amount') > 100) .group_by('region') .agg(pl.col('amount').sum().alias('revenue')) .collect() )这条链路的优化点是:代理 IP 保证采集不被限流,**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">read_csv(StringIO)</font>**省去文件 I/O,Polars Lazy 引擎在**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">collect()</font>**时才触发执行并做全局优化。相比"采集 → 存 CSV → Pandas 读取 → 分析"的传统链路,整体耗时减少 60% 以上。
八、最终判断
基于实测数据,迁移决策可以拆成三个层次:
第一层:看数据规模
| 数据量 | 推荐方案 |
|---|---|
| < 10 万行 | Pandas,迁移无意义 |
| 10 万~500 万行 | Pandas + PyArrow 后端 |
| 500 万~5000 万行 | Polars(单机最优解) |
| > 5000 万行 | Polars Lazy + DuckDB |
第二层:看瓶颈操作
如果业务中高频使用 Join 和 GroupBy,迁移收益最大——这两个操作 Polars 快 6~7 倍。如果主要是列选择和简单过滤,差距不到 3 倍,迁移必要性不大。
第三层:看生态依赖
重度依赖 scikit-learn、statsmodels、Matplotlib 的项目,最终仍需转回 Pandas。但**<font style="color:rgb(0, 206, 185);background-color:rgb(252, 252, 252);">to_pandas()</font>**的零拷贝转换耗时在毫秒级,不构成实质障碍。
"Polars 碾压 Pandas"这个说法在千万级数据 + 计算密集型操作的条件下成立,在十万级数据 + 简单过滤的场景下不成立。选型的正确姿势是:先用你的真实数据跑一遍 benchmark,再基于实测数字做决策,而不是被社区营销数字牵着走。