Polars — lazy API, query optimizer, columnar Arrow backend
Polars (Polars User Guide, 2020+) — Rust-based DataFrame library с lazy API + query optimizer + columnar Arrow memory backend. Современная alternative pandas — designed для production ETL workloads (single-machine, до ~100GB), не для interactive REPL. M10 урок 01 показал pandas eager model; этот урок объясняет Polars lazy model + parallels к М05 урок 02 (generator lazy iteration).
В этом уроке:
- Why Polars — production контекст + design choices (Rust + Arrow + lazy).
- DataFrame vs LazyFrame — eager
pl.read_csvvs lazypl.scan_csv. - Query optimizer — predicate pushdown + projection pushdown.
- Expression syntax —
pl.col(...).filter(...).sum()— declarative AST. - Eager vs lazy comparison table — pandas vs Polars side-by-side.
- Cross-link M05 урок 02 — generator lazy → Polars lazy at expression level.
- Run-on-Your-Machine #2 — Polars LazyFrame demo.
- Cross-course → DataFusion — parallel logical-plan optimizer architecture.
Why Polars — Rust + Arrow + lazy
pandas (2008) vs Polars (2020) design comparison:
| Aspect | pandas | Polars |
|---|---|---|
| Implementation language | C + Cython + Python | Rust + PyO3 bindings |
| Memory model | NumPy ndarray (default) или Arrow (opt-in pandas 2.0+) | Arrow ChunkedArray (always) |
| Evaluation | eager (computes immediately) | eager OR lazy (recommended lazy для production) |
| Optimizer | none | predicate / projection pushdown, common-subexpression-elimination |
| Multi-threading | mostly single-threaded; GIL | multi-threaded by default (Rust no GIL) |
| API style | row+column with [] | expression-builder pl.col(...) |
Three reasons production teams adopt Polars:
- Rust + Arrow — multi-threaded by default; Arrow columnar layout makes column projection trivial; ~5-10x faster than pandas for medium data.
- Lazy + optimizer —
scan_csv → filter → select → collectrewrites query plan to push filters к scan layer (read меньше bytes from disk). - No GIL — single Polars query can saturate всех cores на single machine; pandas required
daskormodinдля parallelism.
Production positioning: pandas — interactive REPL; Polars — production ETL pipeline (single-machine). Distributed workloads — Spark / DataFusion (cross-course) territory.
DataFrame vs LazyFrame — read_* vs scan_*
import polars as pl
# Eager — DataFrame, computes immediately
df = pl.read_csv('large.csv') # читает весь файл в memory
result = df.filter(pl.col('amount') > 0).select(['category', 'amount'])
# Lazy — LazyFrame, builds query plan
lf = pl.scan_csv('large.csv') # ничего не читает — builds AST
result = (
lf
.filter(pl.col('amount') > 0) # AST node — Filter
.select(['category', 'amount']) # AST node — Projection
.collect() # NOW реально читает + executes optimized plan
)
Critical insight: в lazy mode, scan_csv + filter + select строят query plan AST (similar к SQL parser). .collect() triggers query optimizer + execution. До .collect() ничего не выполняется.
Why scan вместо read для production: позволяет optimizer переписать plan ПЕРЕД I/O — например, filter(amount > 0) push-down к scan layer means Polars читает только rows где amount > 0 (если файл columnar Parquet — skip целые row groups). Без lazy mode это невозможно.
Cite Polars User Guide — Lazy API.
Query optimizer — predicate / projection pushdown
import polars as pl
result = (
pl.scan_csv('orders.csv')
.select(['user_id', 'amount', 'date', 'category', 'currency']) # projection
.filter(pl.col('amount') > 100) # predicate
.filter(pl.col('category') == 'tech') # predicate
.group_by('user_id')
.agg(pl.col('amount').sum())
.collect()
)
Optimizer rewrites plan:
-
Predicate pushdown —
filter(amount > 100) AND filter(category == 'tech')пушится к scan layer. Еслиorders.csv— Parquet, optimizer использует row-group min/max statistics (cross-link M09 урок 04 binary-formats-overview + Storage Formats M02) и читает только row groups гдеmax(amount) > 100 AND category in unique_values. -
Projection pushdown — final result использует только
user_idиamount(после group_by + agg).selectдекларирует 5 колонок, ноdateиcurrencyдальше не используются. Optimizer drops их к scan layer — читаются только 2 колонки из CSV/Parquet. -
Common subexpression elimination — если
pl.col('amount') * 1.2появляется дважды, evaluator вычисляет один раз и кэширует. -
Type coercion —
pl.col('amount') > 100(int) — auto-cast еслиamount— float64 (no manual cast).
Цена этих оптимизаций — 0 строк user-кода. Это и есть pedagogical insight: lazy позволяет машине переписывать ваш запрос, eager — нет.
Inspect optimized plan:
print(lf.explain()) # textual representation: filter pushdown, projection pruning visible
Cite Polars User Guide — Optimizations.
Expression syntax — pl.col(...).filter(...).sum()
Polars expressions — declarative AST nodes, built via method-chain:
import polars as pl
# Single column expression
expr1 = pl.col('amount') # column reference
# Method chain — filter + aggregation
expr2 = pl.col('amount').filter(pl.col('y') > 0).sum()
# Cross-column expression
expr3 = (pl.col('amount') * pl.col('quantity')).alias('total')
# Wildcard expression — apply к всем колонкам
expr4 = pl.col('*').exclude('id').mean() # mean каждой колонки кроме 'id'
# Используется в select / with_columns / agg
df.select([
pl.col('user_id'),
expr2.alias('positive_sum'),
expr3,
])
Key idea: expressions — values (можно сохранить в variable, передать в функцию, reuse). Не df['col'].filter(...) (как в pandas, где filter — method DataFrame), а pl.col('col').filter(...) (где filter — method expression). Это даёт type-safe + composable API.
Cross-link к Polars Expr API: docs.pola.rs/api/python/stable/reference/expressions.
Eager vs lazy — pandas vs Polars side-by-side
| Aspect | pandas (eager) | Polars (lazy via scan_*) |
|---|---|---|
| When computed | immediately | at .collect() call |
| Memory model | numpy.ndarray backing (или Arrow opt-in 2.0+) | Arrow ChunkedArray (always) |
| Optimizer | none (executes as-written) | predicate / projection pushdown, CSE, type coercion |
| Mutation | df['x'] = ... in-place common | immutable + chain only |
| API style | df.method(args) | df.method(pl.expr1, pl.expr2) |
| Multi-threading | mostly GIL-bound | multi-core by default (Rust) |
| Failure mode | runtime errors (KeyError, TypeError per row) | schema-validated at plan-build (early errors) |
| Best for | interactive REPL / small data | production ETL / medium-large data |
Production rule: для production pipelines — Polars lazy (scan_* → … → .collect()); для interactive analysis в Jupyter — pandas (или Polars eager).
Cross-link M05 урок 02 — generator lazy → Polars lazy at expression level
Concept ladder — lazy evaluation в Python:
| Level | Mechanism | Example | M_module |
|---|---|---|---|
| 1. Iteration-level | yield keyword | def chunks(): yield from f | M05 урок 02 (generator climax) |
| 2. Expression-level | query plan AST | pl.col(...).filter(...) | M10 урок 02 (this lesson) |
| 3. Distributed plan | RDD lineage / DAG | df.filter(...).select(...) (Spark) | Spark course (cross-course) |
Same idea — defer computation; build representation; execute later — applied at three abstraction levels.
Cross-link М05 урок 02 (generators-pygen-frames) — PyGenObject сохраняет state между next() calls; computation defers per-step (lazy iteration). Polars LazyFrame делает то же самое на уровне query plan — defer execution до .collect().
Cross-link forward к DataFusion M02 architecture (logical-plan) — DataFusion — Rust-native query engine с той же logical-plan-optimizer architecture (DataFusion использует Apache Arrow + Datafusion-expr crate). Если поняли Polars lazy — поняли DataFusion 80%.
Run-on-Your-Machine — Polars LazyFrame demo
Run-on-Your-Machine: Polars LazyFrame demo
Установите локально (Polars Rust binary 30MB+; не bundled в Pyodide default):
pip install 'polars>=1.0,<2.0'Создайте файл data.csv:
category,amount
food,10
food,20
tech,100
tech,200
travel,50Создайте файл polars_demo.py:
import polars as pl
lf = pl.scan_csv('data.csv') # LazyFrame — нет реального чтения
result = (
lf
.filter(pl.col('amount') > 0)
.group_by('category')
.agg(pl.col('amount').mean().alias('mean_amount'))
.collect() # NOW реально читает + выполняет optimized plan
)
print(result)
# Inspect optimized plan
print(lf.filter(pl.col('amount') > 0).explain())Запустите:
python3 polars_demo.py.explain() напечатает план выполнения — увидите predicate pushdown в действии (filter пушится к Csv SCAN). Version pin >=1.0,<2.0 (Pitfall 32) — Polars 1.x stable API; 2.x возможен с breaking changes.
Cross-course → DataFusion architecture + query optimization
DataFusion — Rust-native SQL/DataFrame query engine, использует Apache Arrow как in-memory representation. Architectural parallel к Polars:
-
DataFusion M02 architecture — query pipeline / logical plan / physical plan / catalog. Polars LazyFrame ≈ DataFusion logical plan. Если понимаете Polars
.explain()output — поймёте DataFusiondf.explain()output аналогично. -
DataFusion M06 query-optimization — predicate pushdown / projection pushdown / join optimization / common subexpression elimination — те же rewrites которые Polars делает internally. DataFusion exposes них explicitly как optimizer rules в Rust API.
Cross-course bridge insight: Polars + DataFusion разделяют design DNA — оба Rust-based, оба Arrow-backed, оба lazy + optimizer. DataFusion (более низкоуровневый, designed для embeddable engine) — Polars (более высокоуровневый, designed для DataFrame DSL). Полезно прочитать DataFusion M02 чтобы понять “как работает” query optimizer внутри Polars.
Что в следующем уроке
М10 урок 03 — PyArrow (Apache Arrow Python bindings). Полный stack: pandas (eager) → Polars (lazy) → PyArrow (memory layer). Урок 03 покрывает Arrow memory model + zero-copy semantics + heavy cross-course → Storage Formats M07 (7 уроков deep dive в Arrow internals).