刚接触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五个字段,我们要完成以下任务:
- 读取CSV。
- 过滤掉
amount为空的记录。 - 新增一列
unit_price,等于amount除以quantity。 - 按
region和category分组,计算总销售额和平均单价。 - 筛选出总销售额超过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丰富的生态支持,算是目前我自己最顺手的工作流。