polarspandasPythonоптимизацияbig data

polars vs pandas 2026: lazy evaluation, query optimizer и когда переходить

2026-06-02 14 мин

pandas на 1M строк — комфортно. На 10M — тяжеловато. На 50M+ — постоянно OutOfMemory. Аналитики Wildberries, Yandex, Avito переходят на polars или DuckDB для middle-big data.

Этот гайд — про практическое сравнение polars vs pandas: чем отличаются под капотом, когда и как мигрировать, какие подводные.


Зачем polars: реальные проблемы pandas

Проблема 1. Single-thread → не использует все ядра

pandas написан на Python + NumPy. Каждый groupby / merge / apply идёт в один поток (GIL не отпускается).

import pandas as pd
import time

df = pd.read_csv('events_10M.csv')  # 1.4 GB

start = time.time()
result = df.groupby(['user_id', 'event_type']).agg({'amount': 'sum'})
print(f"pandas: {time.time() - start:.2f}s")
# pandas: 18.4s (1 ядро)

Проблема 2. Memory-hungry

pandas хранит каждую колонку как NumPy array → object dtype для строк = огромный overhead. На 50M строк × 30 колонок легко 30+ GB RAM.

Проблема 3. Type coercion

int + NaN = float. Колонка с NULL автоматом конвертится в float64 → точность теряется на больших ID (int64float64 уже на 16M+).

Проблема 4. Eager evaluation

Каждая операция выполняется сразу. Нельзя optimizers like predicate pushdown — pandas не знает про следующую операцию.

df = pd.read_csv('huge.csv')  # читает 5 GB целиком
df = df[df.region == 'RU']     # затем фильтрует до 50 MB
df.groupby('user_id').sum()    # далее агрегирует
# 4.95 GB прочитано впустую

Внутреннее устройство polars

polars написан на Rust + Apache Arrow (columnar memory format). Это даёт:

Expression API

import polars as pl

df = pl.read_csv('events_10M.csv')

# Expressions описывают трансформацию декларативно
result = df.group_by(['user_id', 'event_type']).agg(
    pl.col('amount').sum().alias('total'),
    pl.col('amount').mean().alias('avg'),
    pl.col('order_id').n_unique().alias('orders')
)

Под капотом polars компилирует expression в Rust-операции и выполняет parallel.


lazy vs eager evaluation: query optimizer

import polars as pl

# Eager — как pandas
df = pl.read_csv('huge.csv')
df = df.filter(pl.col('region') == 'RU')
result = df.group_by('user_id').agg(pl.col('amount').sum())

# Lazy — query optimizer
result = (
    pl.scan_csv('huge.csv')                     # описание плана
    .filter(pl.col('region') == 'RU')           # описание фильтра
    .group_by('user_id')
    .agg(pl.col('amount').sum())
    .collect()                                  # ВЫПОЛНЕНИЕ
)

В lazy-mode polars анализирует весь pipeline перед выполнением и применяет:

На 50M-строчном датасете lazy mode часто 3-10× быстрее eager.

EXPLAIN показывает план:
result.explain()
# FILTER [(col("region")) == ("RU")]
#   FROM SCAN parquet/csv/...
#     PROJECT [user_id, amount, region]

Шаг 1-5: классические pandas-операции → polars

Шаг 1. read_csv → scan_csv (lazy)

# pandas
df = pd.read_csv('events.csv')

# polars eager
df = pl.read_csv('events.csv')

# polars lazy (рекомендуется)
df = pl.scan_csv('events.csv')  # план, не данные

Шаг 2. groupby

# pandas
df.groupby('user_id').agg({'amount': 'sum', 'order_id': 'count'})

# polars
df.group_by('user_id').agg(
    pl.col('amount').sum(),
    pl.col('order_id').count()
)

Шаг 3. merge / join

# pandas
df_orders.merge(df_users, on='user_id', how='left')

# polars
df_orders.join(df_users, on='user_id', how='left')

Шаг 4. window-функции

# pandas — через transform или rolling
df['avg_7d'] = df.groupby('user_id')['amount'].transform(
    lambda x: x.rolling(7).mean()
)

# polars — over() expressions
df = df.with_columns(
    pl.col('amount').rolling_mean(7).over('user_id').alias('avg_7d')
)

Шаг 5. pivot / melt

# pandas
df.pivot_table(index='date', columns='region', values='amount', aggfunc='sum')

# polars
df.pivot(values='amount', index='date', columns='region', aggregate_function='sum')

# melt (unpivot)
df.melt(id_vars='date', value_vars=['region_a', 'region_b'])

Многопоточность из коробки

import os
# pandas — нужен multiprocessing.Pool
from multiprocessing import Pool

def process_user(uid):
    return df[df.user_id == uid].agg(...)

with Pool(os.cpu_count()) as p:
    results = p.map(process_user, user_ids)
# Сложно, требует pickling, overhead 10-20%

# polars — встроено
result = (
    pl.scan_parquet('huge.parquet')
    .group_by('user_id')
    .agg(pl.col('amount').sum())
    .collect()  # автоматически 8 ядер
)

polars использует work-stealing scheduler на Rust. Не нужен ни multiprocessing, ни Dask, ни joblib для базового parallelism.


Benchmarks: 1M / 10M / 50M строк

Тест: groupby(['user_id', 'event_type']).agg(sum, mean, count) на 4-core machine.

Размерpandaspolars eagerpolars lazyDuckDB
1M0.4s0.2s0.15s0.18s
10M18.4s2.1s1.4s1.8s
50MOOM12s8s9s
200M60s+30s35s

Ключевой инсайт: на >10M polars дает 8-13× speedup vs pandas. На >50M pandas просто не справляется.

JOIN-bench (50M × 5M):

EngineВремяПамять
pandasOOM
polars eager38s18 GB
polars lazy22s10 GB
DuckDB26s8 GB

Когда НЕ polars

sklearn / statsmodels input

# sklearn принимает pandas/numpy
from sklearn.ensemble import RandomForestRegressor

# Нужно конвертировать
X_pd = df_polars.to_pandas()
model = RandomForestRegressor().fit(X_pd, y_pd)

Конверсия polars → pandas через Arrow бесплатна, но в pipeline лишний шаг.

Маленькие данные (<100K строк)

Overhead на init polars context > выигрыш. pandas для ad-hoc analysis удобнее.

Mutation-heavy код

polars immutable (как Spark). Каждая операция возвращает new DataFrame. Если нужно много мутаций (df.loc[df.col == X, 'new_col'] = ...) — pandas синтаксис компактнее.

Команда не знает Rust-style API

polars API ближе к Spark / SQL. Migration требует переучивания: .col().alias() вместо df['col'].rename(). На команде джунов с pandas-опытом — учитывай curve.


DuckDB как промежуточное решение

DuckDB — embedded SQL-движок (как SQLite, но columnar + OLAP).

import duckdb

# Прямо на pandas DataFrame
df = pd.read_csv('events.csv')
result = duckdb.sql("""
    SELECT user_id, SUM(amount) AS total
    FROM df
    WHERE region = 'RU'
    GROUP BY user_id
    HAVING total > 1000
""").df()

# Прямо на Parquet файлах (zero-copy)
result = duckdb.sql("""
    SELECT * FROM read_parquet('huge.parquet')
    WHERE region = 'RU'
""").df()

DuckDB vs polars

DuckDBpolars
APISQLDataFrame expressions
Performance JOINЧуть быстрееЧуть медленнее
Performance groupbyПохожиПохожи
Learning curveSQL знают всеRust-style API
MutationЧерез temp tablesImmutable expressions
Pandas интероп.df() через Arrow.to_pandas() через Arrow

Real-world паттерн: ETL + heavy aggregations через polars/DuckDB, финальный анализ + ML через pandas. Между ними zero-copy через Arrow.


FAQ

Можно мигрировать pandas код инкрементно?

Да. polars поддерживает eager API (pl.from_pandas(df)) — можно переписывать функцию за функцией. Используй df.to_pandas() чтобы вернуться в sklearn/statsmodels.

Type system strictness — это плюс или минус?

Плюс на production. polars не позволит int + str молча → меньше silent bugs. Минус на ad-hoc data exploration — больше явных конверсий.

Memory model: Arrow vs pandas

Arrow — columnar, immutable, zero-copy между процессами. pandas — row-oriented numpy arrays, copy-on-write только с pandas 2.0+. На big data Arrow существенно эффективнее.

GPU support?

polars не имеет GPU backend (на 2026). Если GPU критичен → cuDF (RAPIDS, NVIDIA). polars compensates через efficient CPU multi-threading.

Интероп polars + pandas в одном проекте

Через Arrow zero-copy: pl.from_pandas(df_pd) и df_pl.to_pandas(). Конверсия бесплатна для большинства типов (для object dtype может быть копирование).


Что дальше

Источники

polars не заменяет pandas полностью — это новый инструмент для big-data workloads. Открой Python-тренажёр и натренируй expression-based API на разных задачах — переход с pandas займёт пару недель, а speedup на 50M+ строк окупится сразу.

Python для аналитики на средних/больших данных
532+ Python-задач через Pyodide, 3000+ вопросов с собесов, AI-разбор. Бесплатный старт без регистрации.
Открыть Python-тренажёр →