☰
Polars入门指南:从Pandas痛点看列式存储与惰性计算的优势
2026/10/3 8:02:03 网站建设 项目流程

刚接触Polars的朋友,多半是被两个点吸引过来的:一是它处理大数据集时的速度确实快,二是它的语法跟Pandas有几分神似但又不完全一样。我自己的体会是,Polars并不是Pandas的平替,它背后那套“表达式优先、惰性计算、按列存储”的设计思路,才是它真正值得学习的地方。如果你之前被Pandas处理几千万行数据时的内存占用和等待时间折磨过,那么Polars可能是你今年值得投入时间去了解的一个工具。

这篇教程不需要你有很深的数据分析基础,但你最好对Pandas或者SQL有一点点概念,因为我会时不时拿它们做类比。核心目标只有一个:让你看完之后能上手用Polars写第一段数据处理代码,并且知道它跟Pandas在思路上最大的差异在哪里。

1. 为什么是Polars:先弄清楚它快在哪

1.1 从Pandas的痛点说起

我用Pandas用了不少年头,说实话它已经足够好用了,但有两个问题是绕不开的。

第一个是内存占用。Pandas的DataFrame是按行组织数据的,底层每个列的数据类型经常会被推断成object,尤其是当一列里面混了字符串和缺失值的时候,内存消耗会成倍往上翻。你明明只读了一个1GB的CSV文件,结果内存占用可能到了3GB甚至更多。一旦数据量超过内存容量,Pandas就只能在那慢慢磨,甚至直接报OOM。

第二个是循环遍历低效。Pandas虽然也有矢量化操作,但只要你习惯性地用apply或者iterrows,性能就会断崖式下跌。很多初学者写Pandas代码,实际上是在用Python的for循环逐行处理数据,那速度基本上等于放弃治疗。

Polars之所以能解决这些问题,核心在于它用Rust重写了底层数据结构和计算引擎。它不是Python的语法糖,而是一个真正的列式存储、多核并行计算框架。你可以把Polars理解成“用数据库的执行引擎思路去做DataFrame操作”,这让它在处理大规模数据时有着天然的优势。

1.2 列式存储与并行计算带来的红利

Polars默认按列存储数据,每个列的数据类型是独立、紧凑的,不会因为混入字符串而把整块内存撑爆。举个我实测过的例子:同样一份包含10列、5000万行的数据集,Pandas加载进来大约需要8GB内存,Polars大概只需要2.5GB左右。这个差距在内存吃紧的生产环境里,可能直接决定了你的脚本能不能跑完。

更重要的是,Polars的所有基础操作都是并行的。它内部用Rust的rayon库做了多线程调度,你在单机上跑Polars,它会默认用满所有CPU核心。而Pandas的多数操作是单线程的,除非你手动用multiprocessing去拆数据,否则很难吃满CPU。

所以Polars给我的感觉就是:它不是更快一点的Pandas,而是完全不同的执行模型。你用习惯了Pandas的逐行思考方式之后,再用Polars需要一点思维上的转换,但这个转换一旦完成,写出来的代码性能和表达力都会上一个台阶。

1.3 什么时候不要用Polars

不过Polars也不是银弹。如果你的数据量很小,比如只有几万行,Polars和Pandas的差距几乎感受不到,这时候直接用Pandas反而更方便,毕竟它的生态更成熟,各种库都能直接对接。

另外,Polars目前还不支持非常复杂的索引操作,比如多层索引、时间序列重采样这些Pandas很顺手的功能,Polars还在逐步完善中。如果你每天的工作都是处理时间序列金融数据,而且极度依赖Pandas的resample和shift,那Polars可能不是你的最优选择。

我自己当前的使用习惯是:数据量超过几千万行、需要反复做group_by和join的场景,用Polars;数据量小、需要和Scikit-learn、Matplotlib深度交互的场景,还是用Pandas。两个工具配合使用,反而让整体流程更舒服。

2. 安装与第一个DataFrame

2.1 一分钟完成安装

Polars的安装非常简单,直接用pip就能搞定:

pip install polars

这里有一个很多人踩过的坑,我提前说一下:如果你的环境里有多个Python版本,或者你用的是conda,一定要先确认pip对应的是哪个Python环境。我之前就遇到过装了polars之后import报错,排查了半天发现是装到了另一个环境里。

安装完成后,你可以在Python里确认版本号:

import polars as pl print(pl.__version__)

如果看到输出了一个版本号,比如1.7.1或者更高,那就说明安装成功了。目前Polars的迭代速度非常快,API也在不断优化,如果你发现某些代码在最新版里跑不起来,建议先看一下官方文档里的迁移说明。

2.2 创建DataFrame的三种方式

Polars创建DataFrame的方式比Pandas更简洁。最直接的方式是用字典:

import polars as pl df = pl.DataFrame({ "name": ["Alice", "Bob", "Charlie", "David"], "age": [25, 30, 35, 28], "score": [88.5, 92.0, 79.5, 85.0], }) print(df)

输出结果会是这样的表格形式:

shape: (4, 3) ┌─────────┬─────┬───────┐ │ name ┆ age ┆ score │ │ --- ┆ --- ┆ --- │ │ str ┆ i64 ┆ f64 │ ╞═════════╪═════╪═══════╡ │ Alice ┆ 25 ┆ 88.5 │ │ Bob ┆ 30 ┆ 92.0 │ │ Charlie ┆ 35 ┆ 79.5 │ │ David ┆ 28 ┆ 85.0 │ └─────────┴─────┴───────┘

注意这里和Pandas有个非常明显的区别:Polars显示DataFrame的时候,会顺便把每一列的数据类型也打印出来,比如str、i64、f64。这个细节看着不起眼,但实际用起来非常舒服,你一眼就能看出每列是什么类型,不用再单独跑df.dtypes。

除了字典,Polars也支持从列表的列表创建DataFrame,不过需要同时指定列名:

df2 = pl.DataFrame( [ [1, 2, 3], [4, 5, 6], ], columns=["a", "b", "c"] )

还有一种方式是直接从Pandas的DataFrame转换过来,这在从旧项目迁移时特别有用:

import pandas as pd pdf = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}) df3 = pl.from_pandas(pdf)

反向转换也有现成的函数:

pdf_again = df3.to_pandas()

这个互转功能让我在过渡期省了很大的力气,新代码用Polars写,实在需要Pandas生态的支持时再转过去,兼容性做得相当好。

2.3 Schema、数据类型与LazyFrame初体验

Polars里有一个很重要的概念叫Schema,你可以理解成表的“结构说明书”,它描述了每一列的名称和数据类型。你可以随时查看DataFrame的结构:

print(df.schema)

输出结果类似这样:

{'name': String, 'age': Int64, 'score': Float64}

如果你希望精确控制每一列的类型,也可以在创建DataFrame时显式指定schema。这在读取CSV文件时尤其有用,因为自动推断有时候会猜错类型。

df_with_schema = pl.DataFrame( { "id": ["1", "2", "3"], "value": ["3.14", "2.71", "1.41"], }, schema={ "id": pl.Int64, "value": pl.Float64, }, )

Polars还有一个Pandas没有的杀手级特性:LazyFrame。它不是真实存储的数据,而是一个“查询计划”。你可以把一堆操作定义好,但先不执行,等最后调用.collect()时才真正计算。

lazy_df = df.lazy().filter(pl.col("age") > 28).select(["name", "score"]) # 这里还没真正执行任何计算 print(lazy_df) # 真正触发计算 result = lazy_df.collect() print(result)

惰性计算的好处是,Polars在执行前能看到你所有的操作,然后自动调整执行顺序。比如你先filter再select,它可能会把filter提前到读取数据时就执行,这样内存占用会更小、速度更快。这个优化是框架自动完成的,你只需要把操作用“延迟”的方式串起来。

3. 读取与写出数据:从CSV到Parquet

3.1 用read_csv快速载入数据

实际工作中,我们面对的数据大部分还是CSV文件。Polars的read_csv速度比Pandas快很多,而且默认就会做类型推断和并行读取:

df = pl.read_csv("large_file.csv")

如果文件特别大,我建议加上几个参数来优化读取体验:

df = pl.read_csv( "large_file.csv", separator=",", infer_schema_length=10000, ignore_errors=True, )
  • separator:指定分隔符,默认是逗号,如果是制表符就换成"\t"。
  • infer_schema_length:告诉Polars在推断类型时扫描多少行。默认值是1000行,如果你的文件前1000行里的某些列全是数字,但后面突然出现了字符串,类型推断就会出错。把这个值调大一些会更稳妥,但也会稍微增加读取时间。
  • ignore_errors:遇到解析不了的行直接跳过,而不是让整个程序崩溃。这个参数在你面对脏数据时特别有用,不过用的时候要留意跳过了多少行,别把重要数据悄无声息地丢了。

读取的时候还有一个常见问题是文件编码。默认情况下Polars按UTF-8读取,如果你遇到中文文件用GBK编码,可以这样指定:

df = pl.read_csv("chinese_file.csv", encoding="utf8-lossy")

utf8-lossy是一种容错模式,遇到无法解码的字符时不会报错,而是替换成占位符。如果你需要精准处理GBK文件,建议先用文本编辑器把文件转成UTF-8再读取,这样最保险。

3.2 惰性读取大文件:scan_csv

如果文件实在太大,内存还是吃紧,Polars提供了一个比read_csv更聪明的加载方式——scan_csv。它不会把整个文件一次性载入内存,而是创建一个LazyFrame,等你定义了所有操作之后才真正扫描和加载数据。

lazy_df = pl.scan_csv("very_large_file.csv") result = ( lazy_df .filter(pl.col("amount") > 1000) .group_by("category") .agg(pl.col("amount").sum()) .collect() )

你可能会问,这不就是read_csv加lazy()吗?其实差别很大。scan_csv在底层做了一个叫predicate pushdown的优化,意思是它会把filter条件下推到数据读取阶段。也就是说,Polars在读CSV文件的时候,如果发现某一行的amount小于等于1000,就直接丢掉,不会在内存里保留。而read_csv则是先把所有数据读进来,再执行filter,内存占用自然更大。

实际使用时,scan_csv处理几个GB的CSV文件,配合合理的filter条件,几乎可以达到“边读边过滤”的效果,体验非常流畅。

3.3 写出数据:write_csv与write_parquet

处理完数据之后,最常见的操作就是把结果保存下来。Pandas的to_csv是大家的老熟人了,Polars对应的函数是write_csv:

df.write_csv("output.csv")

如果你希望写出时不要带索引,Pandas里需要设置index=False,Polars默认就不带索引,这一点反而更干净。

我特别想推荐的是Parquet格式。它是一种列式存储格式,压缩率高、读取速度快,特别适合作为中间结果保存。Polars读写Parquet是我的日常操作:

df.write_parquet("output.parquet") df_parquet = pl.read_parquet("output.parquet")

同样一份数据,存成CSV可能是500MB,存成Parquet可能只有80MB,而且读取Parquet的速度比CSV快好几倍。如果你的下游流程还需要再次读取这份数据,强烈建议中间结果用Parquet保存。

4. 核心操作实战:filter、select、with_columns与group_by

4.1 过滤行:filter表达式

Polars的filter对应Pandas里的布尔索引,但写法上更接近于SQL的WHERE。

df_filtered = df.filter(pl.col("age") >= 30)

多个条件可以用&(与)、|(或)组合,记得给每个条件加上括号:

df_filtered = df.filter( (pl.col("age") >= 30) & (pl.col("score") > 80) )

这个写法在Pandas里也很常见,但Polars里pl.col("age")不是立刻取值,而是生成一个表达式对象,这个表达式会延迟到DataFrame执行时才计算。这种设计是Polars高性能的关键之一,因为表达式可以被优化和并行化。

还有一个使用频率很高的小技巧——用is_in筛选列表里的值:

df_filtered = df.filter(pl.col("name").is_in(["Alice", "Bob"]))

这对应SQL里的IN,写起来很直观,性能也不错。

4.2 选择列:select与标准表达式

如果你只需要某几列,直接用select:

df_selected = df.select(["name", "score"])

但select能做的事情远不止选列。你可以在select里直接生成新列,或者做各种计算:

df_result = df.select([ "name", (pl.col("score") * 2).alias("double_score"), (pl.col("age") + 10).alias("age_plus_10"), ])

这里需要解释一下.alias(),它的作用是给计算结果起一个新列名。如果不加alias,新列的名字会自动生成长表达式字符串,看着会很乱。我自己写代码的习惯是,只要做计算就立刻用alias命名,这样后续调试和阅读都会轻松很多。

如果你想对每一列做同一类操作,select也支持通配符,比如选出所有名字列:

df_result = df.select(pl.col("^name.*$"))

这里用了正则表达式来匹配列名,功能很强大,但初学者可以暂时跳过这个用法。

4.3 新增列:with_columns

with_columns和select看起来有点像,但它们有本质区别:select的结果是只保留你选择的列,而with_columns会在原有DataFrame基础上新增或覆盖列,其他列保持不变。

df_with_new = df.with_columns([ (pl.col("score") / 100).alias("score_percent"), ])

这个操作对应Pandas里的df["new_col"] = ...,但Polars的写法更统一,而且可以同时创建多列:

df_with_multi = df.with_columns([ (pl.col("age") * 2).alias("age_double"), (pl.col("score") - pl.col("age")).alias("score_age_diff"), ])

你在写的过程中会发现一个趋势:Polars几乎所有操作都围绕“表达式”展开,pl.col()创建表达式,算术运算得到新表达式,alias()给表达式命名。一旦理解了这条主线,各种操作的复杂度就降低了很多。

4.4 分组聚合:group_by与agg

分组聚合是数据分析里最常用的操作之一。Polars的语法是group_by后面接agg,agg里可以放多个聚合函数,而且支持在同一列上做多个聚合:

df_grouped = df.group_by("name").agg([ pl.col("score").mean().alias("avg_score"), pl.col("score").max().alias("max_score"), pl.col("age").count().alias("count"), ])

Pandas的groupby默认会在分组列上创建索引,需要额外用reset_index()才能恢复成普通列。Polars没有这个问题,分组列就是普通列,结果是一个干净整洁的DataFrame,这点我特别喜欢。

再看一个稍微复杂点的场景,按多个列分组聚合:

df_grouped = df.group_by(["category", "region"]).agg([ pl.col("sales").sum().alias("total_sales"), pl.col("profit").mean().alias("avg_profit"), ])

这种多列分组的写法在Pandas里也支持,但Pandas的返回结果会有MultiIndex,处理起来特别繁琐。Polars直接返回扁平列名的DataFrame,语义清晰,后续再往别的工具导数据也更方便。

4.5 排序与去重

排序用sort,默认升序,加descending=True变成降序:

df_sorted = df.sort("score", descending=True)

多列排序直接传一个列表:

df_sorted = df.sort(["age", "score"], descending=[True, False])

去重用unique,Pandas里也有drop_duplicates,对应关系如下:

df_unique = df.unique(subset=["name"])

如果你希望保留每组的第一条或最后一条记录,可以用unique(subset=["name"], keep="last")。这个功能在清洗日志数据时特别好用,比如同一个用户有多条操作记录,你只想保留最后一次操作。

5. 惰性计算与查询优化:让框架帮你提速

5.1 LazyFrame与collect机制

前面提到过lazy(),这里再深入一点。Polars的惰性计算机制有点类似于SQL数据库的执行计划。你把filter、select、group_by、join这些操作串成一个链条,Polars不会立刻执行每一步,而是先构建一棵“查询计划树”,等你调用.collect()时才一次性执行。

q = ( df.lazy() .filter(pl.col("age") > 25) .select(["name", "score"]) .group_by("name") .agg(pl.col("score").mean()) ) result = q.collect()

这个过程中有几个隐藏的优化点:

  • Predicate pushdown:提前过滤行,减少后续计算量。
  • Projection pushdown:只加载后续用到的列,而不是加载全部列。
  • Join reordering:多个join操作时,自动调整顺序以减少中间结果大小。

这些优化在Pandas里你想做都做不了,因为你每写一步,Pandas就实打实执行一步,没有全局优化空间。而Polars的这种“先规划、再执行”思路,跟现代数据库引擎非常相似,这也是它在复杂数据处理任务里表现出色的原因之一。

5.2 什么时候用惰性计算

惰性计算确实好,但并不是每个场景都必须用。我的建议是:

  • 数据量小(比如几十万行以内),直接用Eager模式就行,代码更直观,调试也方便。
  • 数据量大,或者有多个操作串联、有join时,用Lazy模式会有明显收益。
  • 当你发现一段Pandas代码因为内存爆炸跑不动,可以先用scan_csv改成Lazy模式,再把filter、select这些操作串起来,往往就能解决内存不够的问题。

我遇到过很多次这样的场景:一份3GB的CSV,Pandas直接读就OOM了。但用pl.scan_csv配合合适的过滤条件,内存占用能压到几百MB,跑得又快又稳。这种“起死回生”的体验,只有亲自试过才明白有多爽。

5.3 检查执行计划:explain

如果你好奇Polars到底对你的查询做了什么优化,可以用.explain()来查看执行计划。

plan = q.explain() print(plan)

输出会显示Polars内部的优化流程,比如它把filter下推到了哪一步、把哪些列提前加载了。新手可能看不太懂这些细节,但你可以通过它直观感受到“惰性计算不是在偷懒,而是在提前设计最优路径”。

6. 常见问题与排查技巧

6.1 类型推断错误:混合类型列

CSV文件里如果某一列大部分是数字,但偶尔出现一两条文本,Polars的类型推断可能会失败,导致整列被识别成字符串或者干脆报错。

遇到这种情况,我一般用两种方式解决:

一是读取时指定schema,强制告诉Polars这一列是什么类型:

df = pl.read_csv("file.csv", schema={"age": pl.Int64})

二是读取后手动转换类型:

df = df.with_columns(pl.col("age").cast(pl.Int64, strict=False))

strict=False表示转换失败时用null填充,而不是报错。这个参数我在清洗脏数据时经常用到。

6.2 空值处理:fill_null与drop_nulls

Polars处理空值的函数是fill_null和drop_nulls,跟Pandas的fillna和dropna对应。

df = df.with_columns( pl.col("score").fill_null(0) )

如果你想用每列的平均值填充空值,也可以:

df = df.with_columns( pl.col("score").fill_null(pl.col("score").mean()) )

这个表达式的妙处在于,pl.col("score").mean()本身也是一个表达式,它在执行时才会计算平均值。这种“表达式嵌套表达式”的写法,在Pandas里实现起来就很别扭。

6.3 列名与字符串处理

Polars默认对列名大小写敏感,如果你原文件里的列名是Name,写pl.col("name")是取不到的。这个跟数据库的规则类似,刚开始用的时候容易踩坑。

字符串操作方面,Polars支持的常用函数有str.starts_with、str.contains、str.to_lowercase等:

df = df.with_columns( pl.col("name").str.to_lowercase().alias("name_lower"), pl.col("name").str.contains("li").alias("has_li"), )

这些字符串操作也都是在表达式级别完成的,性能非常不错。我在做文本清洗任务时体验过,处理上千万条文本数据,Polars依然跑得动。

6.4 CSV的编码和写入乱码问题

最后再说一个比较隐蔽的坑。Polars的write_csv默认按UTF-8写出,如果你把结果拿到Excel里打开,中文可能会乱码。解决方法是给出带BOM的UTF-8编码:

df.write_csv("output.csv", encoding="utf8-bom")

这样生成的CSV用Excel打开就能正常显示中文了。这个细节坑过我两次,后来才反应过来。

7. 从Pandas迁移到Polars的心法

7.1 思维转换是关键

迁移最难的不是函数名差异,而是“思维模型”的差异。Pandas更贴近“先有一个完整的表格,然后我用各种方式切它、改它”;Polars更贴近“我把一系列操作定义好,然后让框架一次性执行”。前者是“操作导向”,后者是“查询导向”。

如果你以前写过SQL,你会觉得Polars的表达方式非常熟悉:filter像WHERE,select像SELECT,group_by加agg像GROUP BY加聚合函数。事实上,Polars的设计哲学很大程度上借鉴了SQL查询优化器的思路。

所以我的建议是:不要试图把Pandas代码逐行翻译成Polars,而是把需求重新想一遍,用“查询”的方式来组织你的思路。

7.2 官方文档与独家资料推荐

Polars的官方文档地址是docs.pola.rs,里面的“User Guide”非常详细,而且有大量代码示例。如果你英文阅读没问题,直接从User Guide的“Getting Started”部分看起,会进步得很快。

另外,Polars的GitHub仓库中的README也值得认真读一遍,那里有常用的API对照表和设计思路说明。我一般遇到不熟悉的函数,先查官方文档,再看源码示例,基本都能解决。

7.3 用一个小例子串联全部要点

最后用一个完整的例子来验证你是否真的入门了。假设有一个销售数据文件sales.csv,包含date、region、category、amount、quantity五个字段,我们要完成以下任务:

  1. 读取CSV。
  2. 过滤掉amount为空的记录。
  3. 新增一列unit_price,等于amount除以quantity。
  4. 按region和category分组,计算总销售额和平均单价。
  5. 筛选出总销售额超过10000的分组。

整体代码如下:

import polars as pl result = ( pl.scan_csv("sales.csv") .filter(pl.col("amount").is_not_null()) .with_columns( (pl.col("amount") / pl.col("quantity")).alias("unit_price") ) .group_by(["region", "category"]) .agg([ pl.col("amount").sum().alias("total_sales"), pl.col("unit_price").mean().alias("avg_unit_price"), ]) .filter(pl.col("total_sales") > 10000) .collect() ) print(result)

这段代码把scan_csv、filter、with_columns、group_by、agg、collect全部串起来了。如果你能理解并独立写出来,说明你已经掌握了Polars的核心用法。

我第一次用Polars跑通这个流程的时候,最大的感受是:代码写起来干净利落,不会有Pandas那种“临时变量满天飞”的杂乱感。整个数据流程像一条流水线,每一步都很清晰,跑起来也很快。

最后再分享一个小技巧:如果你在数据处理过程中需要跟Pandas生态交互,可以考虑用Polars处理完大工作量后,再通过.to_pandas()转给下游做可视化或者机器学习建模。这两者结合使用,既能享受Polars的高性能,又能保住Pandas丰富的生态支持,算是目前我自己最顺手的工作流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询