Python技术迷

Polars vs Pandas:千万级数据实测,迁移到底值不值?

同一份 1000 万行 CSV,Pandas 跑到一半内存开始顶,Polars 还在往下走。

这时候再讨论“语法优雅不优雅”,意义不大。我一般先看三个东西:读文件耗时、groupby 耗时、内存峰值。别上来就说迁移,迁移不是换个 import 那么简单。

测试脚本我一般会写得很土,但够用。

import os
import time
import psutil
import pandas as pd
import polars as pl

DATA_FILE = "order_1000w.csv"
N = 10_000_000

defrss_mb():
return psutil.Process(os.getpid()).memory_info().rss / 1024 / 1024

defwatch(name, fn):
    start_mem = rss_mb()
    start = time.perf_counter()
    result = fn()
    cost = time.perf_counter() - start
    end_mem = rss_mb()
    print(f"{name:<22} cost={cost:.2f}s mem+={end_mem - start_mem:.1f}MB")
return result

defbuild_csv():
if os.path.exists(DATA_FILE):
return

    batch = 500_000
    first = True

for base in range(0, N, batch):
        size = min(batch, N - base)
        df = pd.DataFrame({
"order_id": range(base, base + size),
"user_id": [(base + i) % 800_000 for i in range(size)],
"shop_id": [(base + i) % 5000for i in range(size)],
"amount": [((base + i) % 3000) / 10for i in range(size)],
"status": ["paid"if (base + i) % 7else"refund"for i in range(size)]
        })
        df.to_csv(DATA_FILE, index=False, mode="a", header=first)
        first = False

build_csv()

这里我没造复杂业务,就五个字段。订单 ID、用户 ID、店铺 ID、金额、状态。线上表当然比这个脏多了,但压读取、过滤、聚合,够看出味道。

Pandas 这边:

defpandas_job():
    df = pd.read_csv(DATA_FILE)

    paid = df[df["status"] == "paid"]

    report = (
        paid.groupby("shop_id", as_index=False)
            .agg(
                order_cnt=("order_id", "count"),
                user_cnt=("user_id", "nunique"),
                amount_sum=("amount", "sum")
            )
    )

return report.sort_values("amount_sum", ascending=False).head(20)

watch("pandas csv groupby", pandas_job)

Polars 这边我不会一上来就 read_csv,因为那样只用了它一半能力。大文件我更愿意先试 lazy。

defpolars_job():
    report = (
        pl.scan_csv(DATA_FILE)
          .filter(pl.col("status") == "paid")
          .group_by("shop_id")
          .agg([
              pl.len().alias("order_cnt"),
              pl.col("user_id").n_unique().alias("user_cnt"),
              pl.col("amount").sum().alias("amount_sum")
          ])
          .sort("amount_sum", descending=True)
          .limit(20)
          .collect()
    )

return report

watch("polars lazy groupby", polars_job)

跑完以后,差距通常不会太含蓄。

Pandas 的问题不是它慢一点,而是它很容易把整份数据先吞进内存。你后面哪怕只要 status = paid 的数据,它也先把 CSV 读成一个完整 DataFrame。这个动作在百万级没什么,到了千万级就开始有感觉。

Polars 的 lazy 这块比较狠,它会把过滤、聚合、投影往前推。你只要三个字段,它就不会傻乎乎把所有列都完整拉进来再说。这种差别,不是写法差别,是执行计划差别。

我当时第一眼不信的是 join。

因为很多文章只测 groupby,groupby 确实容易让 Polars 好看。但业务里更烦的是宽表拼接,比如订单表拼用户画像、拼店铺维表、拼活动表。这个地方才容易把内存打穿。

defpolars_join_job():
    orders = pl.scan_csv(DATA_FILE).filter(pl.col("status") == "paid")

    shops = pl.DataFrame({
"shop_id": range(5000),
"shop_level": [i % 5for i in range(5000)]
    }).lazy()

return (
        orders.join(shops, on="shop_id", how="left")
              .group_by("shop_level")
              .agg([
                  pl.len().alias("cnt"),
                  pl.col("amount").sum().alias("gmv")
              ])
              .collect()
    )

watch("polars lazy join", polars_join_job)

Pandas 也能写,代码甚至更顺手。

defpandas_join_job():
    orders = pd.read_csv(DATA_FILE)
    orders = orders[orders["status"] == "paid"]

    shops = pd.DataFrame({
"shop_id": range(5000),
"shop_level": [i % 5for i in range(5000)]
    })

    merged = orders.merge(shops, on="shop_id", how="left")

return (
        merged.groupby("shop_level", as_index=False)
              .agg(cnt=("order_id", "count"), gmv=("amount", "sum"))
    )

watch("pandas join", pandas_join_job)

这段如果放在线上离线任务里,我会更偏向 Polars。不是因为 Pandas 不行,而是 Pandas 太容易在中间态把内存吃胖。一个 merge 后面接 groupby,中间 DataFrame 会实打实存在。数据量再翻一倍,机器就开始骂人。

但迁移也不是无脑值。

Pandas 有几个地方还是稳。第一,生态老,异常好搜。第二,很多同事都会,排查成本低。第三,你要是做小数据探索、画图、临时分析,Pandas 仍然舒服。几万行、几十万行的数据,换 Polars 不一定能省多少时间,反而多出一堆语法适配。

我更建议按任务迁,不要按项目迁。

比如这些任务,值得动:

1. CSV / Parquet 超过几 GB
2. groupby、join、窗口计算很多
3. 任务经常因为内存被 kill
4. 只需要读部分列,但 Pandas 每次全量读
5. 数据清洗链路长,中间 DataFrame 一层套一层

这些就先别急:

1. 数据量不到百万
2. 主要靠 Pandas 生态里的老库
3. 团队里没人熟 Polars
4. 代码里大量 apply,逻辑又很碎

尤其是 apply。Pandas 里很多人喜欢一把梭,什么都塞进 apply。迁到 Polars 后如果还这么写,性能不一定好看。Polars 更吃表达式写法。

比如这种 Pandas 代码:

df["risk_tag"] = df.apply(
lambda r: "high"if r["amount"] > 500and r["status"] == "refund"else"normal",
    axis=1
)

迁 Polars 我会改成这样:

df = df.with_columns(
    pl.when((pl.col("amount") > 500) & (pl.col("status") == "refund"))
      .then(pl.lit("high"))
      .otherwise(pl.lit("normal"))
      .alias("risk_tag")
)

这不是语法洁癖。表达式能被优化,逐行 Python 函数不行。这个坑不提前说,迁完以后很容易得出一个奇怪结论:Polars 也没多快。

我的判断比较简单:千万级数据,Polars 值得试,而且最好先试在离线清洗、报表聚合、数据导入导出这些地方。线上核心链路别一下子全换,先拿一条慢任务开刀,保留输入输出一致,再对账。

迁移最终看两张表:耗时表,内存表。

只要这两张表能把机器省下来,把任务稳定性提上去,迁就值。要是只是为了写法新鲜,把 Pandas 全部推倒重写,那八成是在给自己找活。