Polars 完全指南
最后更新:2026-03-27 API 参考:https://docs.pola.rs/api/python/stable/reference/ 1. Polars完全指南 · 安装与版本(/polars-wan-quan-zhi-nan/#%E5%AE%89%E8%A3%85%E4%B8%8E%E7%89%88%E6%9C%AC) 2. Polars完全指南 · 核心概念(/polars-wan-quan-zhi-nan/#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5) 3. Polars完全指南 · 数据创建(/polar
最后更新:2026-03-27
官方文档:https://docs.pola.rs/
适用版本:Polars 1.x(2026-05-08 核实)
API 参考:https://docs.pola.rs/api/python/stable/reference/
目录
- Polars完全指南 · 安装与版本
- Polars完全指南 · 核心概念
- Polars完全指南 · 数据创建
- Polars完全指南 · 数据读写
- Polars完全指南 · 数据查看
- Polars完全指南 · 表达式系统(Expression)
- Polars完全指南 · DataFrame 操作
- Polars完全指南 · 分组聚合(GroupBy)
- Polars完全指南 · 连接操作
- Polars完全指南 · 数据重塑
- Polars完全指南 · 合并
- Polars完全指南 · 字符串操作(str namespace)
- Polars完全指南 · 日期时间操作(dt namespace)
- Polars完全指南 · 惰性执行(LazyFrame)
- Polars完全指南 · 流式处理(Streaming)
- Polars完全指南 · 窗口函数
- Polars完全指南 · 性能特性
- Polars完全指南 · 常用配合库
- Polars完全指南 · 最佳实践
- Polars完全指南 · 应用场景
- Polars完全指南 · 常见陷阱与注意事项
安装与版本
安装
# 基础安装
pip install polars
# 包含所有可选依赖(推荐生产环境)
pip install polars[all]
# 仅包含 pandas 互操作依赖
pip install polars[pandas]
# 仅包含 numpy 依赖
pip install polars[numpy]
# 包含 Excel 读写支持
pip install polars[xlsx2csv,openpyxl]
# 包含数据库连接支持
pip install polars[connectorx]
验证安装
import polars as pl
print(pl.__version__)
Polars vs Pandas 核心对比
| 维度 | Polars | Pandas |
|---|---|---|
| API 风格 | 表达式系统(Expression),链式调用 | 命令式,索引驱动 |
| 执行引擎 | Rust 编写,原生多线程 | Python + NumPy,默认单线程 |
| 内存模型 | Apache Arrow 列式存储 | NumPy 数组,非列式 |
| 多线程 | 自动并行,无需配置 | 需借助 Dask/Modin 等 |
| 惰性求值 | 内置 LazyFrame,支持查询优化 | 无原生惰性支持 |
| 索引 | 无行索引(无 index 概念) | 有行索引(Index 对象) |
| 内存占用 | 通常低 30%~60% | 较高,有对象列开销 |
| 处理速度 | 大数据集通常快 5~20 倍 | 小数据集差距不明显 |
| 生态成熟度 | 较新(2021 年),生态仍在完善 | 成熟,生态完整 |
| 缺失值处理 | null(整数也可为 null)与 NaN 严格区分 | NaN 和 None 混用 |
核心概念
Series 和 DataFrame(Eager 模式)
Series 是一维数组,DataFrame 是二维表格,均基于 Apache Arrow 内存格式。
Eager 模式下操作立即执行并返回结果,适合探索性分析和小数据集。
import polars as pl
# Series
s = pl.Series("age", [25, 30, 35, 28])
print(s.dtype) # Int64
print(s.mean()) # 29.5
# DataFrame
df = pl.DataFrame({
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 30, 35],
"score": [88.5, 92.0, 79.5],
})
print(df)
LazyFrame(Lazy 模式)
LazyFrame 记录操作但不立即执行,调用 .collect() 时才触发计算。Polars 在执行前对查询计划进行优化(谓词下推、投影下推等)。
# 从 DataFrame 转换
lf = df.lazy()
# 构建查询计划(不执行)
result = (
lf
.filter(pl.col("age") > 25)
.select(["name", "score"])
.sort("score", descending=True)
)
# 查看执行计划
print(result.explain())
# 触发执行
df_result = result.collect()
Expression(表达式系统)
表达式是 Polars 最核心的概念。表达式描述对列的操作,可以任意组合,Polars 自动并行执行多个表达式。
# 表达式描述操作,不立即执行
expr = pl.col("age") * 2 + 1
# 在 select/filter/with_columns 中使用
df.select(expr)
df.with_columns(expr.alias("age_double"))
Apache Arrow 内存模型
Polars 底层使用 Apache Arrow 列式存储:
- 同一列的数据在内存中连续存储,CPU 缓存命中率高
- 支持零拷贝与其他 Arrow 兼容库(PyArrow、DuckDB 等)互操作
- 整数列支持 null(不需要像 pandas 那样将整数列转为 float64 以存储 NaN)
数据创建
pl.Series()
创建一维 Series。
pl.Series(name, values, dtype=None, *, strict=True, nan_to_null=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | str | 必填 | Series 名称 |
| values | array-like | 必填 | 数据,支持 list/numpy/pyarrow 等 |
| dtype | PolarsDataType | None | 指定数据类型,None 则自动推断 |
| strict | bool | True | True 时值不符合 dtype 则报错,False 则尝试转换 |
| nan_to_null | bool | False | 将浮点 NaN 转为 null |
s1 = pl.Series("scores", [1, 2, 3, 4, 5])
s2 = pl.Series("prices", [1.5, 2.3, None, 4.1], dtype=pl.Float32)
s3 = pl.Series("flags", [True, False, True])
s4 = pl.Series("names", ["Alice", "Bob", "Charlie"])
pl.DataFrame()
创建二维 DataFrame。
pl.DataFrame(data=None, schema=None, *, schema_overrides=None, strict=True, orient=None, infer_schema_length=100, nan_to_null=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| data | dict / list / numpy / pandas | None | 输入数据 |
| schema | list[str] / dict[str, dtype] | None | 列名或列名到类型的映射 |
| schema_overrides | dict[str, dtype] | None | 覆盖部分列的类型推断结果 |
| strict | bool | True | 类型不匹配时是否报错 |
| orient | "col" / "row" | None | data 为嵌套列表时的方向 |
| infer_schema_length | int | 100 | 推断 schema 时扫描的行数 |
| nan_to_null | bool | False | 将 NaN 转为 null |
# 从字典创建(最常用)
df = pl.DataFrame({
"id": [1, 2, 3],
"name": ["Alice", "Bob", "Charlie"],
"score": [88.5, 92.0, 79.5],
})
# 从列表创建(按行)
df = pl.DataFrame(
[[1, "Alice", 88.5], [2, "Bob", 92.0]],
schema=["id", "name", "score"],
orient="row",
)
# 从列表创建(按列)
df = pl.DataFrame(
[[1, 2, 3], ["Alice", "Bob", "Charlie"]],
schema=["id", "name"],
orient="col",
)
# 指定类型
df = pl.DataFrame(
{"id": [1, 2, 3], "score": [88.5, 92.0, 79.5]},
schema={"id": pl.Int32, "score": pl.Float32},
)
# schema_overrides 只覆盖部分列
df = pl.DataFrame(
{"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"], "score": [88, 92, 79]},
schema_overrides={"score": pl.Float64},
)
从 Pandas 互转
# pl.from_pandas(df, schema_overrides=None, rechunk=True, nan_to_null=True, include_index=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| df | pandas.DataFrame / pandas.Series | 必填 | 输入的 pandas 对象 |
| schema_overrides | dict[str, dtype] | None | 覆盖部分列的类型 |
| rechunk | bool | True | 重新分配内存为单块,提升后续性能 |
| nan_to_null | bool | True | 将 NaN 转为 null |
| include_index | bool | False | 是否将 pandas index 作为列导入 |
import pandas as pd
import polars as pl
pdf = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
# pandas -> polars
df = pl.from_pandas(pdf)
df = pl.from_pandas(pdf, include_index=True)
# polars -> pandas
pdf2 = df.to_pandas()
pdf2 = df.to_pandas(use_pyarrow_extension_array=True) # 保留 null(不转 NaN)
从 NumPy 和 Arrow 创建
import numpy as np
import pyarrow as pa
# 从 numpy 创建
arr = np.array([[1, 2, 3], [4, 5, 6]])
df = pl.from_numpy(arr, schema=["a", "b", "c"])
# Series from numpy
s = pl.Series("data", np.array([1.0, 2.0, 3.0]))
# 从 Arrow Table 创建
table = pa.table({"a": [1, 2, 3], "b": ["x", "y", "z"]})
df = pl.from_arrow(table)
# Polars -> Arrow
arrow_table = df.to_arrow()
数据读写
pl.read_csv()
读取 CSV 文件为 DataFrame(Eager 模式)。
pl.read_csv(source, *, has_header=True, columns=None, new_columns=None, separator=",", comment_prefix=None, quote_char='"', skip_rows=0, skip_rows_after_header=0, dtypes=None, schema=None, schema_overrides=None, null_values=None, missing_utf8_is_empty_string=False, ignore_errors=False, try_parse_dates=False, n_threads=None, infer_schema_length=100, batch_size=8192, n_rows=None, encoding="utf8", low_memory=False, rechunk=True, use_pyarrow=False, storage_options=None, skip_rows=0, row_index_name=None, row_index_offset=0, sample_size=1024, eol_char="\n", raise_if_empty=True, truncate_ragged_lines=False, decimal_comma=False)
常用参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| source | str / Path / bytes | 必填 | 文件路径、URL 或字节内容 |
| has_header | bool | True | 第一行是否为表头 |
| separator | str | "," | 字段分隔符 |
| columns | list[str/int] | None | 只读取指定列(列名或列索引) |
| new_columns | list[str] | None | 为列重命名(按顺序替换) |
| dtypes | dict[str, dtype] | None | 指定列的数据类型(旧参数,建议用 schema_overrides) |
| schema_overrides | dict[str, dtype] | None | 覆盖部分列的推断类型 |
| null_values | str / list / dict | None | 将指定字符串识别为 null |
| ignore_errors | bool | False | 解析失败的行用 null 填充而非报错 |
| try_parse_dates | bool | False | 尝试自动解析日期列 |
| n_rows | int | None | 只读取前 N 行 |
| skip_rows | int | 0 | 跳过文件开头 N 行 |
| skip_rows_after_header | int | 0 | 跳过表头后的 N 行 |
| encoding | str | "utf8" | 文件编码,常用 "utf8-lossy" 容错 |
| low_memory | bool | False | 降低内存使用(牺牲速度) |
| infer_schema_length | int | 100 | 推断类型时扫描的行数,None 表示全部 |
| n_threads | int | None | 并行线程数,None 表示自动 |
| rechunk | bool | True | 读取后重整内存块 |
| row_index_name | str | None | 添加行号列,指定列名 |
| row_index_offset | int | 0 | 行号起始值 |
| truncate_ragged_lines | bool | False | 列数不足的行截断而不报错 |
# 基础读取
df = pl.read_csv("data.csv")
# 常用选项
df = pl.read_csv(
"data.csv",
separator="\t", # Tab 分隔
columns=["id", "name", "score"], # 只读指定列
null_values=["", "NA", "N/A", "null"], # 多种 null 表示
try_parse_dates=True, # 自动解析日期
n_rows=10000, # 只读前 1 万行
schema_overrides={"id": pl.Int32}, # 覆盖类型
encoding="utf8-lossy", # 容忍编码错误
)
# 无表头文件
df = pl.read_csv("data.csv", has_header=False, new_columns=["a", "b", "c"])
pl.read_parquet()
读取 Parquet 文件为 DataFrame。
pl.read_parquet(source, *, columns=None, n_rows=None, use_statistics=True, hive_partitioning=None, hive_schema=None, try_convert_dates=False, parallel="auto", row_index_name=None, row_index_offset=0, low_memory=False, storage_options=None, use_pyarrow=False, pyarrow_options=None, memory_map=True, rechunk=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| source | str / Path / list | 必填 | 文件路径,支持通配符和列表 |
| columns | list[str/int] | None | 只读指定列 |
| n_rows | int | None | 只读前 N 行 |
| use_statistics | bool | True | 利用 Parquet 统计信息加速过滤 |
| parallel | "auto" / "columns" / "row_groups" / "none" | "auto" | 并行读取策略 |
| low_memory | bool | False | 降低内存使用 |
| row_index_name | str | None | 添加行号列 |
| hive_partitioning | bool | None | 是否解析 Hive 分区路径 |
df = pl.read_parquet("data.parquet")
df = pl.read_parquet("data.parquet", columns=["id", "value"])
df = pl.read_parquet("data/*.parquet") # 读取多个文件
pl.read_json() / pl.read_ndjson()
# JSON 数组格式:[{...}, {...}]
df = pl.read_json("data.json")
# NDJSON(每行一个 JSON 对象)格式:
# {"a": 1, "b": "x"}
# {"a": 2, "b": "y"}
df = pl.read_ndjson("data.ndjson")
df = pl.read_ndjson("data.ndjson", schema_overrides={"a": pl.Int32})
pl.read_excel()
pl.read_excel(source, *, sheet_id=None, sheet_name=None, engine=None, engine_options=None, read_options=None, schema_overrides=None, infer_schema_length=100, columns=None, raise_if_empty=True)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| source | str / Path / bytes | 必填 | 文件路径 |
| sheet_id | int | None | 按索引选择 Sheet(从 1 开始) |
| sheet_name | str | None | 按名称选择 Sheet |
| engine | str | None | 解析引擎:"calamine"(推荐)/ "openpyxl" / "xlsx2csv" |
| schema_overrides | dict | None | 覆盖列类型 |
| infer_schema_length | int | 100 | 推断类型扫描行数 |
| columns | list | None | 只读指定列 |
# 需要安装:pip install polars[xlsx2csv] 或 pip install polars[openpyxl]
df = pl.read_excel("data.xlsx")
df = pl.read_excel("data.xlsx", sheet_name="Sheet2", engine="calamine")
pl.scan_csv() / pl.scan_parquet()
返回 LazyFrame,不立即读取数据,配合惰性执行使用。读取大文件的首选方式。
# scan_csv 参数与 read_csv 基本相同,返回 LazyFrame
lf = pl.scan_csv("large_data.csv")
lf = pl.scan_csv(
"large_data.csv",
separator=",",
null_values=[""],
try_parse_dates=True,
schema_overrides={"id": pl.Int32},
)
# scan_parquet 支持通配符和目录
lf = pl.scan_parquet("data.parquet")
lf = pl.scan_parquet("data/*.parquet")
lf = pl.scan_parquet("hive_data/", hive_partitioning=True)
# 配合惰性操作,只读取需要的列和行
result = (
pl.scan_csv("large_data.csv")
.filter(pl.col("date") >= "2024-01-01")
.select(["id", "name", "value"])
.collect()
)
写出文件
df.write_csv()
df.write_csv(file=None, *, has_header=True, separator=",", line_terminator="\n", quote_char='"', batch_size=1024, datetime_format=None, date_format=None, time_format=None, float_precision=None, null_value="", quote_style="necessary")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| file | str / Path / None | None | 输出路径,None 则返回字符串 |
| has_header | bool | True | 是否写出表头 |
| separator | str | "," | 字段分隔符 |
| null_value | str | "" | null 的字符串表示 |
| float_precision | int | None | 浮点数精度 |
| datetime_format | str | None | 日期时间格式字符串 |
| quote_style | str | "necessary" | 引号策略:"necessary" / "always" / "never" / "non_numeric" |
df.write_csv("output.csv")
df.write_csv("output.csv", separator="\t", null_value="NULL")
csv_str = df.write_csv() # 返回字符串
df.write_parquet()
df.write_parquet(file, *, compression="zstd", compression_level=None, statistics=False, row_group_size=None, data_page_size=None, use_pyarrow=False, pyarrow_options=None)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| file | str / Path | 必填 | 输出路径 |
| compression | str | "zstd" | 压缩算法:"lz4" / "uncompressed" / "snappy" / "gzip" / "lzo" / "brotli" / "zstd" |
| compression_level | int | None | 压缩级别(依算法而定) |
| statistics | bool | False | 写入列统计信息(加速谓词下推) |
| row_group_size | int | None | 每个 row group 的行数 |
df.write_parquet("output.parquet")
df.write_parquet("output.parquet", compression="snappy", statistics=True)
df.write_json() / df.write_ndjson()
df.write_json("output.json")
df.write_json("output.json", pretty=True)
df.write_ndjson("output.ndjson")
数据查看
基础查看方法
df.head(5) # 前 N 行,默认 5
df.tail(5) # 后 N 行,默认 5
df.sample(10) # 随机采样 N 行
df.sample(fraction=0.1) # 随机采样 10%
sample() 参数表
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| n | int | None | 采样行数,与 fraction 二选一 |
| fraction | float | None | 采样比例(0.0~1.0) |
| with_replacement | bool | False | 是否有放回采样 |
| shuffle | bool | False | 是否打乱顺序 |
| seed | int | None | 随机种子,保证可复现 |
结构信息
df.shape # (行数, 列数) 元组
df.columns # 列名列表
df.dtypes # 类型列表(与 columns 一一对应)
df.schema # {列名: 类型} 字典
df.height # 行数
df.width # 列数
describe()
生成描述性统计汇总。
df.describe()
df.describe(percentiles=[0.25, 0.5, 0.75])
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| percentiles | list[float] | [0.25, 0.5, 0.75] | 要计算的分位数 |
| interpolation | str | "nearest" | 分位数插值方式 |
glimpse()
Polars 特有方法,纵向展示每列的名称、类型和前几个值,适合列数多的 DataFrame。
df.glimpse()
df.glimpse(max_items_per_column=5, max_colname_length=30)
输出示例:
Rows: 3
Columns: 3
$ id <i64> 1, 2, 3
$ name <str> 'Alice', 'Bob', 'Charlie'
$ score <f64> 88.5, 92.0, 79.5
表达式系统(Expression)
表达式是 Polars 最核心的概念。表达式是对列操作的描述,可自由组合,Polars 会自动并行执行多个无依赖关系的表达式。表达式在 select()、filter()、with_columns()、agg() 等上下文中使用。
pl.col() — 列引用
# 单列
pl.col("name")
pl.col("age")
# 多列(返回多个表达式)
pl.col("age", "score")
pl.col(["age", "score"])
# 正则匹配列名(以 ^ 开头,以 $ 结尾)
pl.col("^score.*$") # 匹配所有以 score 开头的列
# 按 dtype 选择
pl.col(pl.Int64) # 所有 Int64 列
pl.col(pl.Utf8) # 所有字符串列(Utf8 即 String)
pl.col(pl.NUMERIC_DTYPES) # 所有数值类型列
pl.lit() — 字面量
创建常量表达式。
pl.lit(42)
pl.lit("hello")
pl.lit(3.14)
pl.lit(True)
pl.lit(None)
# 使用场景:与列组合
df.with_columns((pl.col("score") + pl.lit(10)).alias("score_adjusted"))
pl.all() / pl.exclude()
pl.all() # 所有列
pl.exclude("id") # 排除单列
pl.exclude(["id", "name"]) # 排除多列
pl.exclude(pl.Utf8) # 排除字符串列
# 示例
df.select(pl.all()) # 等同于 df
df.select(pl.exclude("id")) # 删除 id 列
df.select(pl.all().sort()) # 对所有列排序
算术表达式
pl.col("price") + pl.col("tax")
pl.col("price") * 1.1
pl.col("score") ** 2 # 乘方
pl.col("value") % 10 # 取模
pl.col("a") // pl.col("b") # 整除
比较与逻辑表达式
# 比较
pl.col("age") > 25
pl.col("age") >= 25
pl.col("age") == 25
pl.col("age") != 25
pl.col("age").is_between(20, 30) # 闭区间
pl.col("age").is_between(20, 30, closed="left") # 左闭右开
# 逻辑(推荐用运算符)
(pl.col("age") > 25) & (pl.col("score") > 80) # AND
(pl.col("age") > 25) | (pl.col("score") > 90) # OR
~pl.col("flag") # NOT
# 也可以用方法(更明确)
pl.col("age").gt(25).and_(pl.col("score").gt(80))
pl.col("age").gt(25).or_(pl.col("score").gt(90))
pl.col("flag").not_()
alias() — 别名
pl.col("score").alias("成绩")
(pl.col("price") * 1.1).alias("price_with_tax")
pl.all().name.prefix("new_") # 为所有列名加前缀
pl.all().name.suffix("_orig") # 为所有列名加后缀
cast() — 类型转换
pl.col("id").cast(pl.Int32)
pl.col("score").cast(pl.Float32)
pl.col("flag").cast(pl.Boolean)
pl.col("date_str").cast(pl.Date)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| dtype | PolarsDataType | 必填 | 目标数据类型 |
| strict | bool | True | True 时转换失败报错,False 时失败返回 null |
# 容错转换
pl.col("value").cast(pl.Int64, strict=False) # 无法转换的变为 null
空值处理
pl.col("score").is_null() # 是否为 null,返回布尔 Series
pl.col("score").is_not_null() # 是否非 null
pl.col("value").is_nan() # 是否为 NaN(仅浮点列)
pl.col("value").is_not_nan()
# fill_null:填充 null
pl.col("score").fill_null(0) # 用常量填充
pl.col("score").fill_null(pl.col("score").mean()) # 用均值填充
pl.col("score").fill_null(strategy="forward") # 前向填充
pl.col("score").fill_null(strategy="backward") # 后向填充
pl.col("score").fill_null(strategy="mean") # 用均值填充
pl.col("score").fill_null(strategy="min")
pl.col("score").fill_null(strategy="max")
fill_null 的 strategy 参数:
| strategy | 说明 |
|---|---|
| "forward" | 用前一个非 null 值填充 |
| "backward" | 用后一个非 null 值填充 |
| "mean" | 用列均值填充 |
| "min" | 用列最小值填充 |
| "max" | 用列最大值填充 |
| "zero" | 用 0 填充 |
| "one" | 用 1 填充 |
# fill_nan:填充浮点 NaN
pl.col("value").fill_nan(0.0)
pl.col("value").fill_nan(None) # NaN -> null
# drop_nulls:在 DataFrame 层面删除含 null 的行
df.drop_nulls()
df.drop_nulls(subset=["score"]) # 只检查指定列
排序表达式
pl.col("score").sort() # 升序
pl.col("score").sort(descending=True) # 降序
pl.col("score").sort(nulls_last=True) # null 排在末尾
pl.col("score").arg_sort() # 返回排序索引(位置)
pl.col("name").sort_by("score") # 按另一列排序
pl.col("name").sort_by("score", descending=True)
聚合表达式
pl.col("score").sum()
pl.col("score").mean()
pl.col("score").median()
pl.col("score").std() # 标准差(ddof=1)
pl.col("score").var() # 方差(ddof=1)
pl.col("score").min()
pl.col("score").max()
pl.col("id").count() # 非 null 计数
pl.col("id").len() # 总行数(包含 null)
pl.col("name").n_unique() # 唯一值个数
pl.col("name").first() # 第一个值
pl.col("name").last() # 最后一个值
pl.col("score").quantile(0.9) # 分位数
pl.col("score").sum() / pl.col("score").count() # 自定义聚合
when().then().otherwise() — 条件表达式
Polars 的条件分支,等价于 SQL 的 CASE WHEN,比 pandas apply 快得多。
# 基础用法
pl.when(pl.col("score") >= 90).then(pl.lit("A")).otherwise(pl.lit("B"))
# 多条件链式
grade = (
pl.when(pl.col("score") >= 90).then(pl.lit("A"))
.when(pl.col("score") >= 80).then(pl.lit("B"))
.when(pl.col("score") >= 70).then(pl.lit("C"))
.otherwise(pl.lit("D"))
)
df = df.with_columns(grade.alias("grade"))
# otherwise 引用其他列
result = (
pl.when(pl.col("value").is_null())
.then(pl.col("default_value"))
.otherwise(pl.col("value"))
.alias("value_filled")
)
# otherwise 可以省略(null 填充)
pl.when(pl.col("score") > 100).then(pl.lit(100)) # 超过 100 的截断,其余为 null
字符串表达式(str namespace)
详见 Polars完全指南 · 字符串操作(str namespace)
pl.col("name").str.to_uppercase()
pl.col("text").str.contains("python")
pl.col("date_str").str.strptime(pl.Date, "%Y-%m-%d")
日期时间表达式(dt namespace)
详见 Polars完全指南 · 日期时间操作(dt namespace)
pl.col("date").dt.year()
pl.col("datetime").dt.strftime("%Y-%m-%d")
列表表达式(list namespace)
# 假设列为 List[Int64] 类型
pl.col("scores").list.len() # 每个列表的长度
pl.col("scores").list.sum() # 每个列表元素求和
pl.col("scores").list.mean() # 每个列表均值
pl.col("scores").list.max()
pl.col("scores").list.min()
pl.col("scores").list.first() # 每个列表的第一个元素
pl.col("scores").list.last()
pl.col("scores").list.get(0) # 按索引取元素
pl.col("scores").list.contains(90) # 是否包含某值
pl.col("scores").list.sort() # 排序每个列表
pl.col("scores").list.unique() # 去重每个列表
pl.col("a").list.concat(pl.col("b")) # 连接两个列表列
df = pl.DataFrame({"scores": [[1, 2, 3], [4, 5], [6]]})
df.with_columns(pl.col("scores").list.len().alias("n"))
结构体表达式(struct namespace)
# 创建结构体
df.select(pl.struct("a", "b").alias("ab"))
# 访问字段
pl.col("struct_col").struct.field("a")
pl.col("struct_col").struct.rename_fields(["x", "y"])
# 解构
df.with_columns(pl.col("struct_col").struct.unnest())
DataFrame 操作
select() — 列选取
选择列,可传入表达式。只返回选中的列。
df.select(expr, *more_exprs)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| exprs | str / Expr / list | 必填 | 列名、表达式或其列表 |
df.select("name")
df.select(["name", "score"])
df.select(pl.col("name"), pl.col("score") * 2)
df.select(pl.all().sort()) # 对所有列排序
df.select(pl.col("^score.*$")) # 正则选列
df.select(pl.col(pl.NUMERIC_DTYPES)) # 选所有数值列
df.select((pl.col("score") - pl.col("score").mean()).alias("score_centered"))
filter() — 行过滤
按条件过滤行,保留满足条件的行。
df.filter(expr)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| exprs | Expr / list[Expr] | 必填 | 布尔表达式,多个表达式间为 AND 关系 |
df.filter(pl.col("age") > 25)
df.filter((pl.col("age") > 25) & (pl.col("score") > 80))
# 多表达式参数等价于 AND
df.filter(pl.col("age") > 25, pl.col("score") > 80)
# is_in 过滤
df.filter(pl.col("city").is_in(["Beijing", "Shanghai"]))
# 字符串过滤
df.filter(pl.col("name").str.starts_with("A"))
with_columns() — 添加或修改列
添加新列或修改已有列。原有列不受影响,返回包含所有原列加上新列的 DataFrame。
df.with_columns(expr, *more_exprs)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| exprs | str / Expr / list | 必填 | 表达式列表,alias 决定列名 |
# 添加新列
df.with_columns((pl.col("price") * 1.1).alias("price_with_tax"))
# 修改已有列(alias 与现有列同名则覆盖)
df.with_columns(pl.col("score").cast(pl.Float32))
# 同时添加多列(并行执行)
df.with_columns(
(pl.col("price") * 1.1).alias("price_tax"),
pl.col("name").str.to_uppercase().alias("name_upper"),
pl.col("score").fill_null(0).alias("score_filled"),
)
rename()
重命名列。
df.rename(mapping)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| mapping | dict[str, str] | 必填 | {旧列名: 新列名} 字典 |
df.rename({"name": "user_name", "score": "user_score"})
drop()
删除列。
df.drop(columns, *more_columns)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| columns | str / list[str] | 必填 | 要删除的列名 |
df.drop("id")
df.drop(["id", "temp_col"])
sort()
按列排序。
df.sort(by, *, descending=False, nulls_last=False, multithreaded=True, maintain_order=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| by | str / Expr / list | 必填 | 排序列或表达式 |
| descending | bool / list[bool] | False | 是否降序,可按列分别指定 |
| nulls_last | bool | False | null 值是否排在末尾 |
| multithreaded | bool | True | 是否多线程排序 |
| maintain_order | bool | False | 相同值保持原始顺序(稳定排序,较慢) |
df.sort("score")
df.sort("score", descending=True)
df.sort(["score", "name"], descending=[True, False])
df.sort("score", nulls_last=True)
unique()
去除重复行。
df.unique(subset=None, *, keep="any", maintain_order=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| subset | list[str] | None | 用于判断重复的列,None 则考虑所有列 |
| keep | str | "any" | 保留哪条:"any"(任意一条)/ "first"(第一条)/ "last"(最后一条)/ "none"(删除所有重复) |
| maintain_order | bool | False | 保持原始行顺序(较慢) |
df.unique()
df.unique(subset=["user_id"])
df.unique(subset=["user_id"], keep="last", maintain_order=True)
slice()
按位置切片。
df.slice(offset, length=None)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| offset | int | 必填 | 起始位置(支持负数,从末尾计) |
| length | int | None | 切片长度,None 则取到末尾 |
df.slice(10, 20) # 第 10~29 行
df.slice(-5) # 最后 5 行
数据提取
# 获取单行(返回 dict)
df.row(0)
df.row(0, named=True) # 返回 {列名: 值} 的字典
# 获取多行(返回 list of tuple)
df.rows()
df.rows(named=True) # 返回 list of dict
# 转为 Series
df.to_series(0) # 按索引
df["name"] # 按列名(等同于 df.get_column("name"))
# 转为字典列表
df.to_dicts() # [{列名: 值}, ...]
# 转为 numpy
df.to_numpy() # 二维 numpy 数组
df["score"].to_numpy() # 一维数组
分组聚合(GroupBy)
group_by()
按列分组。
df.group_by(by, *more_by, maintain_order=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| by | str / Expr / list | 必填 | 分组列 |
| maintain_order | bool | False | 保持分组顺序(稍慢),默认结果顺序不保证 |
.agg() — 聚合
# 基础聚合
df.group_by("category").agg(
pl.col("score").mean().alias("avg_score"),
pl.col("score").max().alias("max_score"),
pl.col("id").count().alias("count"),
)
# 对所有数值列求均值
df.group_by("category").agg(pl.col(pl.NUMERIC_DTYPES).mean())
# 多组聚合
df.group_by(["category", "region"]).agg(
pl.col("revenue").sum(),
pl.col("quantity").sum(),
pl.n_unique("user_id").alias("unique_users"),
)
# 收集列值到列表
df.group_by("user_id").agg(pl.col("action").alias("actions"))
# 保证顺序
df.group_by("category", maintain_order=True).agg(pl.col("score").mean())
group_by_dynamic() — 动态分组(时序)
基于时间窗口的分组,处理时序数据的核心方法。
df.group_by_dynamic(index_column, *, every, period=None, offset=None, label="left", include_boundaries=False, closed="left", group_by=None, start_by="window")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| index_column | str | 必填 | 时间索引列(必须已排序) |
| every | str / timedelta | 必填 | 窗口间隔,如 "1d" / "1h" / "30m" / "1w" |
| period | str / timedelta | None | 窗口大小,默认等于 every(滚动窗口) |
| offset | str / timedelta | None | 窗口偏移量 |
| label | str | "left" | 窗口标签:"left"(起始)/ "right"(结束)/ "datapoint" |
| include_boundaries | bool | False | 是否在结果中包含窗口起止列 |
| closed | str | "left" | 窗口边界:"left" / "right" / "both" / "none" |
| group_by | str / list | None | 额外的分组键 |
| start_by | str | "window" | 窗口起始策略 |
# 按日聚合
df.sort("timestamp").group_by_dynamic("timestamp", every="1d").agg(
pl.col("value").sum().alias("daily_sum"),
pl.col("value").mean().alias("daily_avg"),
)
# 按小时聚合,并按 user_id 额外分组
df.sort("timestamp").group_by_dynamic(
"timestamp", every="1h", group_by="user_id"
).agg(
pl.col("event_count").sum(),
)
# 滑动窗口(period > every)
df.sort("date").group_by_dynamic(
"date", every="1d", period="7d" # 每天一个 7 天滑动窗口
).agg(pl.col("sales").sum().alias("rolling_7d_sum"))
时间间隔字符串说明:
| 字符串 | 含义 |
|---|---|
| "1ns" | 1 纳秒 |
| "1us" | 1 微秒 |
| "1ms" | 1 毫秒 |
| "1s" | 1 秒 |
| "1m" | 1 分钟 |
| "1h" | 1 小时 |
| "1d" | 1 天 |
| "1w" | 1 周 |
| "1mo" | 1 个月 |
| "1q" | 1 季度 |
| "1y" | 1 年 |
group_by_rolling()
基于行的滑动窗口分组(已被 group_by_dynamic 的 period 参数覆盖,较少使用)。
df.sort("date").group_by_rolling(
index_column="date",
period="7d",
group_by="category",
).agg(pl.col("sales").sum())
连接操作
join()
DataFrame 连接。
df.join(other, on=None, how="inner", *, left_on=None, right_on=None, suffix="_right", validate="m:m", join_nulls=False, coalesce=None)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| other | DataFrame | 必填 | 右侧 DataFrame |
| on | str / list | None | 两侧同名的连接键,与 left_on/right_on 二选一 |
| how | str | "inner" | 连接类型,见下表 |
| left_on | str / list | None | 左侧连接键(列名不同时使用) |
| right_on | str / list | None | 右侧连接键 |
| suffix | str | "_right" | 右侧重名列的后缀 |
| validate | str | "m:m" | 键唯一性校验:"1:1" / "1:m" / "m:1" / "m:m" |
| join_nulls | bool | False | null 是否视为相等的键 |
| coalesce | bool | None | 是否合并同名键列 |
how 类型说明:
| how | SQL 等价 | 说明 |
|---|---|---|
| "inner" | INNER JOIN | 只保留两侧都有匹配键的行 |
| "left" | LEFT JOIN | 保留左侧所有行,右侧无匹配则 null |
| "right" | RIGHT JOIN | 保留右侧所有行,左侧无匹配则 null |
| "full" | FULL OUTER JOIN | 保留两侧所有行 |
| "semi" | LEFT SEMI JOIN | 保留左侧中键在右侧出现的行(不附加右侧列) |
| "anti" | LEFT ANTI JOIN | 保留左侧中键不在右侧出现的行 |
| "cross" | CROSS JOIN | 笛卡尔积,所有行组合 |
df_orders = pl.DataFrame({"order_id": [1, 2, 3], "user_id": [101, 102, 101], "amount": [50.0, 80.0, 120.0]})
df_users = pl.DataFrame({"user_id": [101, 102, 103], "name": ["Alice", "Bob", "Charlie"]})
# 内连接
df_orders.join(df_users, on="user_id")
# 左连接(不同列名)
df_orders.join(df_users, left_on="user_id", right_on="user_id", how="left")
# Anti join:找出没有匹配用户的订单
df_orders.join(df_users, on="user_id", how="anti")
# 列名冲突处理
df1.join(df2, on="id", suffix="_df2")
join_asof()
时序对齐连接(AsOf Join)。按最近的键匹配,而非精确匹配,常用于将事件数据与价格/汇率等时序数据对齐。
df.join_asof(other, *, on=None, left_on=None, right_on=None, by=None, by_left=None, by_right=None, strategy="backward", suffix="_right", tolerance=None, coalesce=True)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| other | DataFrame | 必填 | 右侧 DataFrame(必须已按 on 列排序) |
| on | str | None | 两侧同名的连接键(通常是时间列) |
| left_on / right_on | str | None | 两侧列名不同时使用 |
| by | str / list | None | 精确匹配的额外分组键(如股票代码) |
| strategy | str | "backward" | 匹配策略,见下表 |
| tolerance | int / float / str | None | 允许的最大偏差(超过则返回 null) |
strategy 说明:
| strategy | 说明 |
|---|---|
| "backward" | 取右侧中小于等于左侧键的最大值(向前看价格,最常用) |
| "forward" | 取右侧中大于等于左侧键的最小值 |
| "nearest" | 取右侧中距离最近的键 |
df_trades = pl.DataFrame({
"time": [1, 2, 5, 8],
"trade_id": [1, 2, 3, 4],
}).sort("time")
df_prices = pl.DataFrame({
"time": [1, 3, 6],
"price": [100.0, 105.0, 110.0],
}).sort("time")
# 每笔交易匹配最近的(不晚于交易时间的)价格
df_trades.join_asof(df_prices, on="time", strategy="backward")
# 按股票分组的时序对齐
df_trades.join_asof(df_prices, on="time", by="symbol", strategy="backward")
# 设置容忍度:超过 2 秒则不匹配
df_trades.join_asof(df_prices, on="time", strategy="backward", tolerance=2)
数据重塑
unpivot() / melt()
宽格式转长格式(Wide to Long)。melt() 是 unpivot() 的别名,行为相同。
df.unpivot(on=None, *, index=None, variable_name="variable", value_name="value")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| on | list[str] | None | 要转为长格式的列(值列),None 则转除 index 外所有列 |
| index | list[str] | None | 保持不变的标识列(id 列) |
| variable_name | str | "variable" | 新列名(存放原列名) |
| value_name | str | "value" | 新列名(存放原值) |
df = pl.DataFrame({
"id": [1, 2],
"jan": [100, 200],
"feb": [110, 220],
"mar": [120, 230],
})
df.unpivot(on=["jan", "feb", "mar"], index="id", variable_name="month", value_name="sales")
# 结果:
# id | month | sales
# 1 | jan | 100
# 1 | feb | 110
# ...
pivot()
长格式转宽格式(Long to Wide)。
df.pivot(on, *, index=None, values=None, aggregate_function="first", maintain_order=True, sort_columns=False, separator="_")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| on | str / list | 必填 | 值将成为新列名的列 |
| index | str / list | None | 行标识列,None 则用其余所有列 |
| values | str / list | None | 填入值的列,None 则用除 on 和 index 外的列 |
| aggregate_function | str | "first" | 聚合函数:"first" / "last" / "min" / "max" / "sum" / "mean" / "count" |
| maintain_order | bool | True | 保持行顺序 |
| sort_columns | bool | False | 是否对新生成的列名排序 |
df_long = pl.DataFrame({
"id": [1, 1, 2, 2],
"month": ["jan", "feb", "jan", "feb"],
"sales": [100, 110, 200, 220],
})
df_long.pivot(on="month", index="id", values="sales")
# 结果:
# id | jan | feb
# 1 | 100 | 110
# 2 | 200 | 220
explode()
将列表列展开为多行。
df.explode(columns, *more_columns)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| columns | str / list | 必填 | 要展开的列表列 |
df = pl.DataFrame({"id": [1, 2], "tags": [["python", "data"], ["sql"]]})
df.explode("tags")
# 结果:
# id | tags
# 1 | python
# 1 | data
# 2 | sql
unnest()
将结构体列(Struct)拆分为多个普通列。
df.unnest(columns, *more_columns)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| columns | str / list | 必填 | 要展开的结构体列 |
df = pl.DataFrame({"id": [1, 2], "info": [{"name": "Alice", "age": 25}, {"name": "Bob", "age": 30}]})
df.unnest("info")
# 结果:id | name | age
合并
pl.concat()
合并多个 DataFrame 或 Series。
pl.concat(items, *, how="vertical", rechunk=False, parallel=True)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| items | list[DataFrame/Series/LazyFrame] | 必填 | 要合并的对象列表 |
| how | str | "vertical" | 合并方式,见下表 |
| rechunk | bool | False | 合并后重整内存块(消耗时间,但后续操作更快) |
| parallel | bool | True | 是否并行读取各分片 |
how 类型说明:
| how | 说明 |
|---|---|
| "vertical" | 纵向拼接(行合并),列必须相同 |
| "vertical_relaxed" | 纵向拼接,类型不同时自动转换 |
| "diagonal" | 纵向拼接,列可不同,缺失列填 null |
| "diagonal_relaxed" | 纵向拼接,列可不同,类型不同自动转换 |
| "horizontal" | 横向拼接(列合并),行数必须相同 |
| "align" | 按公共列对齐后合并 |
# 纵向合并(行追加)
df_all = pl.concat([df1, df2, df3])
# 列不完全相同的合并(缺失列补 null)
df_all = pl.concat([df1, df2], how="diagonal")
# 横向合并(列追加)
df_wide = pl.concat([df_a, df_b], how="horizontal")
# LazyFrame 合并
lf_all = pl.concat([lf1, lf2])
字符串操作(str namespace)
通过 pl.col("col").str.方法() 调用。
str.contains()
pl.col("text").str.contains(pattern, *, literal=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pattern | str | 必填 | 匹配模式(正则或字面量) |
| literal | bool | False | True 则视为字面量,False 则视为正则 |
pl.col("text").str.contains("python")
pl.col("text").str.contains(r"py\w+", literal=False)
pl.col("url").str.contains("https://", literal=True)
pl.col("name").str.starts_with("A")
pl.col("email").str.ends_with(".com")
str.replace() / str.replace_all()
pl.col("text").str.replace(pattern, value, *, literal=False, n=1)
pl.col("text").str.replace_all(pattern, value, *, literal=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pattern | str | 必填 | 匹配模式 |
| value | str | 必填 | 替换内容,支持正则捕获组引用 $1 |
| literal | bool | False | 是否字面量匹配 |
| n | int | 1 | replace() 中替换的最大次数 |
pl.col("text").str.replace("foo", "bar")
pl.col("text").str.replace_all(r"\s+", " ") # 合并多个空格
pl.col("text").str.replace_all(r"(\w+)", "[$1]", literal=False) # 正则替换
str.strip_chars() 系列
pl.col("text").str.strip_chars() # 去除两端空白
pl.col("text").str.strip_chars(" \t") # 去除两端指定字符
pl.col("text").str.lstrip_chars() # 去除左端空白
pl.col("text").str.rstrip_chars() # 去除右端空白
str.split()
pl.col("text").str.split(by, *, inclusive=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| by | str | 必填 | 分隔符 |
| inclusive | bool | False | 是否在每个分割结果中保留分隔符 |
pl.col("csv_line").str.split(",") # 返回 List[String] 列
pl.col("csv_line").str.split(",").list.get(0) # 取第一个分割结果
str.extract() / str.extract_groups()
# 提取第一个捕获组
pl.col("text").str.extract(r"(\d+)", group_index=1)
# 提取所有捕获组为结构体
pl.col("text").str.extract_groups(r"(\w+)-(\d+)")
# 返回 Struct{field_0: String, field_1: String}
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| pattern | str | 必填 | 正则表达式(必须含捕获组) |
| group_index | int | 1 | extract() 中要提取的捕获组索引 |
大小写转换
pl.col("text").str.to_uppercase()
pl.col("text").str.to_lowercase()
pl.col("text").str.to_titlecase()
长度计算
pl.col("text").str.len_chars() # 字符数(Unicode 字符)
pl.col("text").str.len_bytes() # 字节数
数值转换
pl.col("num_str").str.to_integer(base=10, strict=True) # 字符串转整数
pl.col("num_str").str.to_decimal(inference_length=100) # 字符串转 Decimal
str.strptime() — 字符串转日期时间
pl.col("date_str").str.strptime(dtype, format=None, *, strict=True, exact=True, cache=True, use_earliest=None, ambiguous="raise")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| dtype | pl.Date / pl.Datetime / pl.Time | 必填 | 目标日期时间类型 |
| format | str | None | 格式字符串(如 "%Y-%m-%d"),None 则自动推断 |
| strict | bool | True | 解析失败时报错(False 则返回 null) |
| exact | bool | True | 是否要求整串匹配(False 则允许前缀匹配) |
| cache | bool | True | 是否缓存解析结果(重复值多时加速) |
| use_earliest | bool | None | 夏令时模糊时段,是否取最早的时间 |
| ambiguous | str | "raise" | 夏令时模糊处理:"raise" / "earliest" / "latest" / "null" |
pl.col("date_str").str.strptime(pl.Date, "%Y-%m-%d")
pl.col("dt_str").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S")
pl.col("dt_str").str.strptime(pl.Datetime("us", "Asia/Shanghai"), "%Y-%m-%d %H:%M:%S")
pl.col("date_str").str.strptime(pl.Date, strict=False) # 解析失败返回 null
日期时间操作(dt namespace)
通过 pl.col("col").dt.方法/属性 调用。
时间属性
pl.col("date").dt.year()
pl.col("date").dt.month()
pl.col("date").dt.day()
pl.col("date").dt.hour()
pl.col("date").dt.minute()
pl.col("date").dt.second()
pl.col("date").dt.millisecond()
pl.col("date").dt.microsecond()
pl.col("date").dt.nanosecond()
pl.col("date").dt.weekday() # 星期几(1=周一 ... 7=周日)
pl.col("date").dt.week() # 一年中的第几周(ISO 周)
pl.col("date").dt.day_of_year() # 一年中的第几天
pl.col("date").dt.quarter() # 季度(1~4)
pl.col("date").dt.ordinal_day() # 儒略历天数
dt.strftime()
pl.col("date").dt.strftime(format)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| format | str | 必填 | 格式字符串,与 Python strftime 相同 |
pl.col("datetime").dt.strftime("%Y-%m-%d")
pl.col("datetime").dt.strftime("%Y/%m/%d %H:%M:%S")
pl.col("datetime").dt.strftime("%Y年%m月%d日")
dt.truncate()
截断到指定时间粒度(如将时间截断到小时)。
pl.col("datetime").dt.truncate(every, offset="0ns")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| every | str / timedelta | 必填 | 截断粒度,如 "1h" / "1d" / "15m" |
| offset | str / timedelta | "0ns" | 偏移量 |
pl.col("datetime").dt.truncate("1h") # 截断到小时
pl.col("datetime").dt.truncate("1d") # 截断到天
pl.col("datetime").dt.truncate("15m") # 截断到 15 分钟
dt.offset_by()
日期偏移运算。
pl.col("date").dt.offset_by(by)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| by | str | 必填 | 偏移量,正数向后,负数向前,如 "1d" / "-7d" / "1mo" / "1y" |
pl.col("date").dt.offset_by("1d") # 加 1 天
pl.col("date").dt.offset_by("-7d") # 减 7 天
pl.col("date").dt.offset_by("1mo") # 加 1 个月(自动处理月末)
pl.col("date").dt.offset_by("1y") # 加 1 年
Duration 方法
当列为 Duration 类型时使用。
pl.col("duration").dt.total_seconds()
pl.col("duration").dt.total_milliseconds()
pl.col("duration").dt.total_microseconds()
pl.col("duration").dt.total_nanoseconds()
pl.col("duration").dt.total_minutes()
pl.col("duration").dt.total_hours()
pl.col("duration").dt.total_days()
# Duration 通过两个日期相减得到
df.with_columns((pl.col("end_date") - pl.col("start_date")).alias("duration"))
pl.date_range() / pl.datetime_range()
生成日期/时间序列。
pl.date_range(start, end, interval="1d", *, eager=False, name=None)
pl.datetime_range(start, end, interval="1h", *, time_unit="us", time_zone=None, eager=False, name=None)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| start | date / datetime / str / Expr | 必填 | 起始时间 |
| end | date / datetime / str / Expr | 必填 | 结束时间(包含) |
| interval | str / timedelta | "1d" | 间隔 |
| eager | bool | False | True 则立即返回 Series,False 则返回表达式 |
| time_unit | str | "us" | 时间精度:"ns" / "us" / "ms" |
| time_zone | str | None | 时区,如 "Asia/Shanghai" |
import datetime
# 生成日期序列
dates = pl.date_range(
datetime.date(2024, 1, 1),
datetime.date(2024, 12, 31),
interval="1mo",
eager=True,
)
# 在表达式中使用(用于 with_columns)
df.with_columns(
pl.datetime_range(pl.col("start"), pl.col("end"), "1d").alias("dates")
)
时区处理
# 为无时区的 Datetime 设置时区(声明,不转换)
pl.col("dt").dt.replace_time_zone("Asia/Shanghai")
# 转换时区(转换,保持绝对时间不变)
pl.col("dt").dt.convert_time_zone("America/New_York")
惰性执行(LazyFrame)
LazyFrame 是 Polars 高性能的核心。所有操作构建执行计划,collect() 时触发优化并执行。
创建 LazyFrame
# 从 DataFrame 转换
lf = df.lazy()
# 直接从文件扫描(最佳实践)
lf = pl.scan_csv("data.csv")
lf = pl.scan_parquet("data.parquet")
lf = pl.scan_ndjson("data.ndjson")
lf.collect()
触发执行,返回 DataFrame。
lf.collect(*, streaming=False, background=False, comm_subplan_elim=True, projection_pushdown=True, predicate_pushdown=True, cluster_with_columns=True, no_optimization=False, slice_pushdown=True, new_streaming=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| streaming | bool | False | 启用流式处理,减少内存峰值(部分操作不支持) |
| no_optimization | bool | False | 禁用所有优化(调试用) |
| projection_pushdown | bool | True | 是否启用投影下推 |
| predicate_pushdown | bool | True | 是否启用谓词下推 |
| comm_subplan_elim | bool | True | 公共子计划消除 |
result = lf.collect()
result = lf.collect(streaming=True) # 流式执行,适合大数据集
查看执行计划
# 文本形式的逻辑计划
print(lf.explain())
# 文本形式的优化后计划
print(lf.explain(optimized=True))
# 可视化执行计划(需要安装 graphviz)
lf.show_graph()
lf.show_graph(optimized=True)
# 收集执行统计信息
result, stats = lf.profile()
谓词下推(Predicate Pushdown)
Polars 自动将 filter() 条件尽量靠近数据源执行,减少读取的数据量。
# Polars 会将 filter 推到 scan_csv 层面,只读取满足条件的行
result = (
pl.scan_csv("large_data.csv")
.filter(pl.col("year") == 2024) # 谓词下推:读 CSV 时就过滤
.filter(pl.col("amount") > 1000) # 多个 filter 都会被下推
.select(["id", "date", "amount"])
.collect()
)
投影下推(Projection Pushdown)
Polars 自动只读取 select() 中实际用到的列,对 Parquet 等列式格式效果显著。
# 只读取 id 和 amount 两列
result = (
pl.scan_parquet("large_data.parquet")
.select(["id", "amount"]) # 投影下推:只读这两列
.filter(pl.col("amount") > 1000)
.collect()
)
何时用 LazyFrame,何时用 Eager
| 场景 | 推荐模式 |
|---|---|
| 数据文件较大(>500MB) | Lazy + scan_* |
| 多步操作链 | Lazy,collect() 一次触发 |
| 交互式探索少量数据 | Eager |
| 需要中间结果进行条件分支 | 在关键点 collect(),再 lazy() |
| 内存受限的大数据集 | Lazy + collect(streaming=True) |
lf.sink_parquet() / lf.sink_csv()
流式写出,不将结果加载到内存。
lf.sink_parquet(path, *, compression="zstd", compression_level=None, statistics=False, row_group_size=None, data_page_size=None, maintain_order=True)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| path | str / Path | 必填 | 输出路径 |
| compression | str | "zstd" | 压缩算法 |
| maintain_order | bool | True | 是否保持行顺序 |
# 大数据集流式写出,不占用内存
(
pl.scan_csv("large_input.csv")
.filter(pl.col("status") == "active")
.with_columns(pl.col("amount") * 1.1)
.sink_parquet("output.parquet")
)
(
pl.scan_parquet("large.parquet")
.select(["id", "name", "value"])
.sink_csv("output.csv")
)
流式处理(Streaming)
collect(streaming=True)
result = lf.collect(streaming=True)
流式处理原理:Polars 将数据分批处理,每批处理完后释放内存,峰值内存大幅降低。适合数据集大小超出可用内存的场景。
适用场景
- 数据集大于可用内存的 50% 时考虑使用
- ETL 管道:扫描大文件,过滤,写出
- 配合
sink_parquet()/sink_csv()实现全程流式处理
# 全程流式,内存占用极低
(
pl.scan_csv("100gb_data.csv")
.filter(pl.col("date") >= "2024-01-01")
.group_by("category")
.agg(pl.col("value").sum())
.sink_parquet("result.parquet")
)
流式处理的限制
部分操作目前不支持流式处理,会自动回退到非流式模式:
sort()(全局排序需要全量数据)join()中的部分类型pivot()- 某些窗口函数
Polars 会自动降级,不会报错,但内存使用会升高。
窗口函数
over() — 窗口聚合
Polars 的窗口函数语法,等同于 SQL 的 OVER (PARTITION BY ...)。与 group_by 不同,over() 不减少行数,每行保留,但值替换为窗口聚合结果。
pl.col("expr").over(partition_by, *, order_by=None, mapping_strategy="group_to_rows")
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| partition_by | str / list | 必填 | 分区键,相当于 PARTITION BY |
| order_by | str / Expr | None | 窗口内排序键(用于 rank/cumsum 等需要顺序的聚合) |
| mapping_strategy | str | "group_to_rows" | 结果映射策略,一般不需要修改 |
# 每行添加所在分组的聚合值
df.with_columns(
pl.col("score").mean().over("department").alias("dept_avg_score"),
pl.col("score").max().over("department").alias("dept_max_score"),
pl.col("score").count().over("department").alias("dept_count"),
)
# 组内排名
df.with_columns(
pl.col("score").rank(descending=True).over("department").alias("dept_rank")
)
# 组内累积和(需要 order_by)
df.with_columns(
pl.col("sales").cum_sum().over("store", order_by="date").alias("cumulative_sales")
)
# 组内 lag/lead(需要 order_by)
df.with_columns(
pl.col("price").shift(1).over("symbol", order_by="date").alias("prev_price")
)
rolling() 表达式
在时间窗口内进行滚动计算。
pl.col("value").rolling_mean(window_size, *, weights=None, min_periods=None, center=False)
pl.col("value").rolling_sum(window_size, *, weights=None, min_periods=None, center=False)
pl.col("value").rolling_std(window_size, *, weights=None, min_periods=None, center=False, ddof=1)
pl.col("value").rolling_min(window_size, *, min_periods=None, center=False)
pl.col("value").rolling_max(window_size, *, min_periods=None, center=False)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| window_size | int / str | 必填 | 窗口大小(行数或时间字符串) |
| weights | list[float] | None | 窗口权重 |
| min_periods | int | None | 窗口内最少有效值数,默认等于 window_size |
| center | bool | False | 是否居中窗口(而非右对齐) |
df.with_columns(
pl.col("price").rolling_mean(7).alias("ma7"), # 7 日均线
pl.col("price").rolling_mean(30, min_periods=1).alias("ma30"), # 允许不足 30 天
pl.col("price").rolling_std(20).alias("volatility"),
)
性能特性
自动多线程并行
Polars 使用 Rust 的 Rayon 线程池,自动并行化以下操作:
- 多个无依赖的
with_columns()表达式并行执行 - group_by / join 使用多线程
- 文件读取并行化
- LazyFrame 查询计划中并行节点同步执行
无需任何配置,自动利用所有 CPU 核心。
Arrow 列式存储优势
- 同列数据内存连续,SIMD 向量化指令生效,聚合操作极快
- 列式读取 Parquet 时可跳过不需要的列(零 IO)
- 与 DuckDB、PyArrow 等 Arrow 生态库零拷贝互操作
与 Pandas 性能对比
| 操作 | Polars 优势说明 |
|---|---|
| groupby + agg | 多线程哈希聚合,通常快 5~15 倍 |
| filter | 谓词下推 + SIMD,快 3~10 倍 |
| join | 并行哈希 join,快 3~10 倍 |
| read_csv | 并行解析,快 5~20 倍 |
| sort | 并行排序,快 3~8 倍 |
| apply(map_elements) | 接近 pandas apply,优势消失 |
内存使用对比
- Polars 使用 Arrow 格式,整数列不需要为 null 转为 float64
- 字符串使用 LargeUtf8 编码,避免对象数组的开销
- 通常比 pandas 少用 30%~60% 内存
- LazyFrame + streaming 可处理超出 RAM 的数据集
用 lf.explain() 分析查询计划
lf = (
pl.scan_parquet("large.parquet")
.filter(pl.col("year") == 2024)
.group_by("category")
.agg(pl.col("value").sum())
)
# 查看未优化计划
print(lf.explain(optimized=False))
# 查看优化后计划(确认谓词下推是否生效)
print(lf.explain(optimized=True))
# 查看执行耗时分布(用于性能调优)
result, profile = lf.profile()
print(profile)
常用配合库
NumPy
import numpy as np
# numpy -> polars Series
arr = np.array([1.0, 2.0, 3.0])
s = pl.Series("data", arr)
# numpy -> polars DataFrame
arr2d = np.random.rand(100, 3)
df = pl.from_numpy(arr2d, schema=["a", "b", "c"])
# polars Series -> numpy
arr = df["score"].to_numpy() # 无 null 时效率最高
arr = df["score"].to_numpy(allow_copy=True) # 有 null 时需要 allow_copy
# polars DataFrame -> numpy
arr2d = df.to_numpy()
Pandas
import pandas as pd
# pandas -> polars
df_pl = pl.from_pandas(pdf)
df_pl = pl.from_pandas(pdf, nan_to_null=True, include_index=True)
# polars -> pandas
pdf = df_pl.to_pandas()
pdf = df_pl.to_pandas(use_pyarrow_extension_array=True) # 保留 null 语义
PyArrow
import pyarrow as pa
# polars -> arrow
arrow_table = df.to_arrow()
# arrow -> polars
df = pl.from_arrow(arrow_table)
# 零拷贝:polars 和 arrow 共享内存(不需要拷贝数据)
Matplotlib / Seaborn 绘图
Polars 无原生绘图支持,通过转换为 pandas 使用:
import matplotlib.pyplot as plt
import seaborn as sns
# 转换后绘图
pdf = df.to_pandas()
pdf.plot(x="date", y="value")
sns.histplot(pdf["score"])
plt.show()
# 或直接取 numpy 数组
plt.scatter(df["x"].to_numpy(), df["y"].to_numpy())
DuckDB — SQL on Polars
DuckDB 可以直接查询 Polars DataFrame(Arrow 协议,无拷贝):
import duckdb
df = pl.DataFrame({"id": [1, 2, 3], "value": [10, 20, 30]})
# 直接用 SQL 查询 Polars DataFrame
result = duckdb.sql("SELECT id, value * 2 as doubled FROM df").pl()
# 复杂 SQL
conn = duckdb.connect()
result = conn.execute("""
SELECT category, SUM(amount) as total
FROM df
GROUP BY category
ORDER BY total DESC
""").pl() # .pl() 返回 Polars DataFrame
Connectorx — 高性能数据库读取
import connectorx as cx
# 高性能从数据库读取,直接返回 Polars DataFrame(Arrow 协议)
df = cx.read_sql(
"postgresql://user:pass@host:5432/db",
"SELECT * FROM orders WHERE status = 'active'",
return_type="polars",
)
最佳实践
优先使用 LazyFrame + collect() 模式
# 不推荐:每步操作立即执行,无法优化
df1 = df.filter(pl.col("year") == 2024)
df2 = df1.select(["id", "value"])
df3 = df2.group_by("id").agg(pl.col("value").sum())
# 推荐:构建完整计划后一次执行,Polars 自动优化
result = (
df.lazy()
.filter(pl.col("year") == 2024)
.select(["id", "value"])
.group_by("id")
.agg(pl.col("value").sum())
.collect()
)
用 select + with_columns 而不是逐列赋值
# 不推荐(pandas 风格,Polars 不支持就地赋值)
# df["new_col"] = df["price"] * 1.1 # Polars 不支持此语法
# 推荐:with_columns 批量添加(多列并行执行)
df = df.with_columns(
(pl.col("price") * 1.1).alias("price_tax"),
pl.col("name").str.to_uppercase().alias("name_upper"),
(pl.col("score") - pl.col("score").mean()).alias("score_norm"),
)
链式表达式风格(Method Chaining)
result = (
pl.scan_csv("data.csv")
.filter(pl.col("date") >= "2024-01-01")
.filter(pl.col("amount") > 0)
.with_columns(
(pl.col("amount") * pl.col("quantity")).alias("revenue"),
pl.col("date").str.strptime(pl.Date, "%Y-%m-%d"),
)
.group_by(["category", pl.col("date").dt.month().alias("month")])
.agg(
pl.col("revenue").sum().alias("total_revenue"),
pl.col("amount").mean().alias("avg_amount"),
)
.sort("total_revenue", descending=True)
.collect()
)
用 when/then/otherwise 替代 map_elements
# 不推荐:map_elements 走 Python 循环,极慢
df.with_columns(
pl.col("score").map_elements(lambda x: "A" if x >= 90 else "B").alias("grade")
)
# 推荐:when/then/otherwise 在 Rust 层执行,极快
df.with_columns(
pl.when(pl.col("score") >= 90).then(pl.lit("A"))
.when(pl.col("score") >= 80).then(pl.lit("B"))
.otherwise(pl.lit("C"))
.alias("grade")
)
避免 map_elements(相当于 pandas apply)
# 仅在没有原生表达式替代时才使用 map_elements
# 尽量找到对应的原生表达式
# 字符串处理:用 str namespace
pl.col("text").map_elements(lambda x: x.upper()) # 不推荐
pl.col("text").str.to_uppercase() # 推荐
# 数学计算:用算术表达式
pl.col("x").map_elements(lambda x: x ** 2) # 不推荐
pl.col("x") ** 2 # 推荐
# 条件逻辑:用 when/then/otherwise
# (见上例)
尽量在 LazyFrame 阶段完成 filter
# 推荐:filter 在 collect 之前,利用谓词下推
result = (
pl.scan_parquet("huge_file.parquet")
.filter(pl.col("status") == "active") # 读取时就过滤
.collect()
)
# 不推荐:collect 后再 filter,已经将全量数据加载到内存
result = pl.read_parquet("huge_file.parquet").filter(pl.col("status") == "active")
读大文件用 scan_* 而不是 read_*
# 不推荐:一次性加载全部数据到内存
df = pl.read_csv("100gb.csv")
# 推荐:惰性扫描,配合过滤和投影只读需要的数据
result = (
pl.scan_csv("100gb.csv")
.filter(pl.col("region") == "China")
.select(["id", "name", "value"])
.collect()
)
应用场景
大型 CSV 数据分析流水线
result = (
pl.scan_csv(
"sales_data.csv",
try_parse_dates=True,
null_values=["", "N/A"],
schema_overrides={"amount": pl.Float64},
)
.filter(
(pl.col("date") >= datetime.date(2024, 1, 1)) &
(pl.col("status") == "completed")
)
.with_columns(
pl.col("amount").fill_null(0).alias("amount"),
pl.col("date").dt.month().alias("month"),
pl.col("date").dt.year().alias("year"),
(pl.col("amount") * pl.col("quantity")).alias("revenue"),
)
.group_by(["year", "month", "category"])
.agg(
pl.col("revenue").sum().alias("total_revenue"),
pl.col("amount").mean().alias("avg_amount"),
pl.col("id").count().alias("order_count"),
)
.sort(["year", "month", "total_revenue"], descending=[False, False, True])
.collect()
)
时序数据处理
df = pl.read_csv("sensor_data.csv", try_parse_dates=True)
result = (
df
.sort("timestamp")
.group_by_dynamic("timestamp", every="1h", group_by="sensor_id")
.agg(
pl.col("temperature").mean().alias("avg_temp"),
pl.col("temperature").max().alias("max_temp"),
pl.col("temperature").min().alias("min_temp"),
pl.col("temperature").std().alias("std_temp"),
pl.col("value").count().alias("reading_count"),
)
.with_columns(
pl.col("avg_temp").rolling_mean(24).over("sensor_id").alias("ma_24h")
)
)
ETL 数据转换
result = (
pl.scan_parquet("raw_orders.parquet")
.with_columns(
# 清洗空值
pl.col("amount").fill_null(0),
pl.col("customer_name").fill_null("Unknown"),
# 类型转换
pl.col("order_date").str.strptime(pl.Date, "%Y-%m-%d"),
# 派生列
(pl.col("amount") * pl.col("tax_rate")).alias("tax_amount"),
# 分类
pl.when(pl.col("amount") > 10000).then(pl.lit("large"))
.when(pl.col("amount") > 1000).then(pl.lit("medium"))
.otherwise(pl.lit("small"))
.alias("order_size"),
)
.filter(pl.col("status").is_in(["active", "completed"]))
.rename({"customer_name": "name", "order_date": "date"})
.collect()
)
result.write_parquet("clean_orders.parquet", compression="zstd", statistics=True)
替换 Pandas 的迁移模式
| 操作 | Pandas 写法 | Polars 写法 |
|---|---|---|
| 读取 CSV | pd.read_csv("f.csv") |
pl.read_csv("f.csv") |
| 过滤行 | df[df["age"] > 25] |
df.filter(pl.col("age") > 25) |
| 选择列 | df"name", "age" |
df.select(["name", "age"]) |
| 添加列 | df["new"] = df["a"] + 1 |
df.with_columns((pl.col("a") + 1).alias("new")) |
| 分组聚合 | df.groupby("cat")["val"].sum() |
df.group_by("cat").agg(pl.col("val").sum()) |
| 排序 | df.sort_values("col", ascending=False) |
df.sort("col", descending=True) |
| 去重 | df.drop_duplicates(subset=["id"]) |
df.unique(subset=["id"]) |
| 宽转长 | df.melt(id_vars=["id"]) |
df.unpivot(index=["id"]) |
| 条件赋值 | df["col"] = np.where(cond, a, b) |
df.with_columns(pl.when(cond).then(a).otherwise(b).alias("col")) |
| apply | df["col"].apply(func) |
df["col"].map_elements(func)(尽量避免) |
| fillna | df["col"].fillna(0) |
df.with_columns(pl.col("col").fill_null(0)) |
| merge | pd.merge(df1, df2, on="id") |
df1.join(df2, on="id") |
| concat | pd.concat([df1, df2]) |
pl.concat([df1, df2]) |
| 字符串操作 | df["col"].str.upper() |
pl.col("col").str.to_uppercase() |
| 日期提取 | df["date"].dt.year |
pl.col("date").dt.year() |
常见陷阱与注意事项
Polars 没有 index
Polars DataFrame 没有行索引(不像 pandas 有 Index 对象)。无法通过 df.loc[...] 或 df.iloc[...] 访问。用 filter() 过滤行,用 slice() 按位置切片。
# pandas 风格(在 Polars 中无效)
# df.loc[df["name"] == "Alice"] # 错误
# Polars 正确写法
df.filter(pl.col("name") == "Alice")
df.row(0, named=True) # 按位置取单行
df.slice(0, 10) # 按位置切片
列名重复会报错
Polars 不允许列名重复。join 时如果两侧有同名列(非连接键),会自动加 suffix,但如果 with_columns 操作产生同名列则会覆盖(不报错)。
# join 时处理重名列
df1.join(df2, on="id", suffix="_right")
# 主动重命名避免冲突
df2_renamed = df2.rename({"value": "value_right"})
is_in() vs isin()
Polars 使用 is_in(),没有 pandas 的 isin()。
pl.col("city").is_in(["Beijing", "Shanghai"]) # Polars
# df["city"].isin(["Beijing", "Shanghai"]) # Pandas(Polars 中不存在)
group_by 结果顺序不保证
默认情况下 group_by() 的结果顺序是不确定的(利用并行哈希表,顺序取决于执行)。如需稳定顺序:
# 方法 1:maintain_order=True(稍慢)
df.group_by("category", maintain_order=True).agg(pl.col("value").sum())
# 方法 2:聚合后 sort(推荐,明确意图)
df.group_by("category").agg(pl.col("value").sum()).sort("category")
map_elements 比原生表达式慢很多
# map_elements 调用 Python 解释器处理每个值,破坏 Polars 的 Rust 并行优化
# 性能通常比原生表达式慢 10~100 倍
# 碰到需要 map_elements 的场景,先思考是否有原生替代:
# - 字符串操作 -> str namespace
# - 数学运算 -> 算术表达式
# - 条件分支 -> when/then/otherwise
# - 日期操作 -> dt namespace
# 实在没有原生替代时,才使用 map_elements,并指定 return_dtype 提升性能
pl.col("text").map_elements(custom_func, return_dtype=pl.String)
时区处理:replace_time_zone vs convert_time_zone
# replace_time_zone:声明时区(不改变时间值,仅附加时区信息)
# 用于:从无时区 Datetime 变为有时区 Datetime
pl.col("dt").dt.replace_time_zone("Asia/Shanghai")
# convert_time_zone:转换时区(改变时间值,保持绝对时间不变)
# 用于:在两个时区之间转换
pl.col("dt").dt.convert_time_zone("America/New_York")
# 典型流程:读入 naive datetime -> 声明时区 -> 转换时区
pl.col("dt").dt.replace_time_zone("UTC").dt.convert_time_zone("Asia/Shanghai")
null vs NaN 的严格区分
Polars 严格区分 null(缺失值)和 NaN(浮点非数值),pandas 通常混用两者。
# null:任何类型都可以有,表示"缺失数据"
pl.col("value").is_null()
pl.col("value").fill_null(0)
# NaN:只有浮点列有,表示"不是数字"(如 0/0 的结果)
pl.col("value").is_nan()
pl.col("value").fill_nan(0.0)
pl.col("value").fill_nan(None) # NaN -> null
# 从 pandas/numpy 导入时,NaN 默认转为 null(nan_to_null=True)
df = pl.from_pandas(pdf, nan_to_null=True)
# describe() 对 null 和 NaN 的统计行为不同:
# - null 不参与 mean/sum 等聚合
# - NaN 会使 mean/sum 结果为 NaN(需要先 fill_nan)
join 时列名冲突处理
# join 时,右侧与左侧同名但不是连接键的列,会自动加 suffix
df1 = pl.DataFrame({"id": [1, 2], "value": [10, 20], "name": ["a", "b"]})
df2 = pl.DataFrame({"id": [1, 2], "value": [100, 200], "info": ["x", "y"]})
result = df1.join(df2, on="id")
# 结果列:id, value, name, value_right, info
# value_right 是 df2 的 value 列
# 自定义 suffix
result = df1.join(df2, on="id", suffix="_df2")
# 结果列:id, value, name, value_df2, info
其他常见注意事项
# 1. Polars 列名区分大小写
df.select("Name") # 与 "name" 是不同的列
# 2. 链式操作不修改原 DataFrame(不可变),必须赋值
df.sort("score") # 不修改 df
df = df.sort("score") # 正确
# 3. 多个 filter 等价于 AND,不是 OR
df.filter(pl.col("a") > 1, pl.col("b") > 2) # a > 1 AND b > 2
# 4. 惰性模式下的 collect 是唯一触发点,不要在循环中 collect
for item in items:
lf = lf.filter(...) # 只构建计划,不执行
result = lf.collect() # 最后一次性执行
# 5. LazyFrame 不支持 len() / shape,需要 collect 后获取
lf.collect().shape
# 或用 lf.select(pl.len()).collect().item() 获取行数(不加载全部数据)