PandasユーザーのためのPolars入門 ― 合成データで体感するLazy実行・最適化・SQLサポート
近年、データ処理の高速化や大規模データ対応を目的として、Polars へ移行を検討するケースが増えています。
Polarsは単なる「速いPandas」ではありません。
設計思想そのものが異なり、クエリエンジン型のデータフレームライブラリといえます。
本記事では、Pandas経験者を対象に:
- PandasとPolarsの設計思想の違い
- Lazy実行と最適化の仕組み
- Streamingによるメモリ効率
- SQLサポート
- 実際に動かせるサンプルコード
を、サンプルデータを作成し、そのデータを使って解説します。
外部データのダウンロードは不要です。
すべてこの記事内のコードだけで再現できます。
環境準備
python -m venv .venv
source .venv/bin/activate
pip install -U polars pandas pyarrow numpy
デモ用データを生成する
今回は架空のEC購買ログを生成します。
- 実在企業・実在人物のデータは含みません
- ランダム生成なので著作権の問題はありません
- Parquet形式で保存します(Polarsと相性が良い)
make_data.py
import numpy as np
import pandas as pd
def make_dim_tables(n_users: int, n_items: int, seed: int = 42):
rng = np.random.default_rng(seed)
users = pd.DataFrame({
"user_id": np.arange(1, n_users + 1, dtype=np.int32),
"region": rng.choice(["Kanto", "Kansai", "Tohoku", "Chubu", "Kyushu"], size=n_users),
"segment": rng.choice(["free", "standard", "premium"], size=n_users, p=[0.55, 0.35, 0.10]),
})
items = pd.DataFrame({
"item_id": np.arange(1, n_items + 1, dtype=np.int32),
"category": rng.choice(
["book", "food", "fashion", "device", "beauty", "toy", "home"],
size=n_items
),
"base_price": rng.integers(300, 20000, size=n_items, dtype=np.int32),
})
return users, items
def make_fact_table(n_rows: int, n_users: int, n_items: int, seed: int = 42):
rng = np.random.default_rng(seed)
start = np.datetime64("2025-01-01")
seconds = rng.integers(0, 365*24*60*60, size=n_rows)
ts = start + seconds.astype("timedelta64[s]")
df = pd.DataFrame({
"ts": ts,
"user_id": rng.integers(1, n_users + 1, size=n_rows),
"item_id": rng.integers(1, n_items + 1, size=n_rows),
"quantity": rng.integers(1, 6, size=n_rows),
"unit_price": rng.integers(300, 20000, size=n_rows),
"discount": rng.choice([0.0, 0.05, 0.1, 0.2], size=n_rows),
})
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"])
return df
def main():
n_rows = 1_000_000
n_users = 200_000
n_items = 20_000
users, items = make_dim_tables(n_users, n_items)
orders = make_fact_table(n_rows, n_users, n_items)
users.to_parquet("users.parquet", index=False)
items.to_parquet("items.parquet", index=False)
orders.to_parquet("orders.parquet", index=False)
print("Data generated.")
if __name__ == "__main__":
main()
実行:
python make_data.py
これにより、users.parquet(20万)、items.parquet(2万)、orders.parguet(100万)の3つのパーケットファイルが作成されます。

PandasとPolarsの基本的な違い
| Pandas | Polars | |
|---|---|---|
| 実行モデル | 即時評価 | 即時 + 遅延 |
| 並列処理 | 基本単一スレッド | 自動並列 |
| 最適化 | なし | クエリ最適化あり |
| 大規模データ | やや不向き | 得意 |
Polarsは「書いた順に実行」ではなく、最適化してから実行する設計です。
■ 基本的な集計処理の比較
Pandas
import pandas as pd
df = pd.read_parquet("orders.parquet")
result = (
df[df["revenue"] > 5000]
.groupby("user_id")["revenue"]
.sum()
.sort_values(ascending=False)
.head(10)
)
print(result)
Polars(Eager):即時実行
import polars as pl
df = pl.read_parquet("orders.parquet")
result = (
df.filter(pl.col("revenue") > 5000)
.group_by("user_id")
.agg(pl.col("revenue").sum())
.sort("revenue", descending=True)
.head(10)
)
print(result)
- read_parquetは全データを読み込みます。
Polarsの本領発揮(Lazy):遅延実行
import polars as pl
lf = pl.scan_parquet("orders.parquet")
result = (
lf.filter(pl.col("revenue") > 5000)
.group_by("user_id")
.agg(pl.col("revenue").sum())
.sort("revenue", descending=True)
.head(10)
.collect()
)
print(result)
ここで重要なのは:
scan_parquet()→ まだ読み込まないcollect()→ ここで初めて実行- その直前に最適化
■ 最適化を可視化する
import polars as pl
lf = (
pl.scan_parquet("orders.parquet")
.filter(pl.col("revenue") > 5000)
.select(["user_id", "revenue"])
)
print("=== Unoptimized ===")
print(lf.explain(optimized=False))
print("\n=== Optimized ===")
print(lf.explain(optimized=True))
explain(optimized=True/False) は、最適化前後の実行プランを表示します。
この実行プランから
- 必要な列だけ読む(Projection Pushdown)
- フィルタを読み込み時点で適用(Predicate Pushdown)
といった最適化が確認できます。
出力結果:
=== Unoptimized ===
SELECT [col("user_id"), col("revenue")]
FILTER [(col("revenue")) > (5000.0)]
FROM
Parquet SCAN [orders.parquet]
PROJECT */7 COLUMNS
ESTIMATED ROWS: 1000000
=== Optimized ===
Parquet SCAN [orders.parquet]
PROJECT 2/7 COLUMNS
SELECTION: [(col("revenue")) > (5000.0)]
ESTIMATED ROWS: 1000000
全体像
同じ処理(filterしてselectする)でも、
- Unoptimized(最適化前):いったん全部読んでから、後段で FILTER / SELECT をかける形
- Optimized(最適化後):読む段階(SCAN)に FILTER / SELECT を押し込んで、最初から無駄を減らす形
Optimized の解説
Parquet SCAN [orders.parquet]
PROJECT 2/7 COLUMNS
SELECTION: [(col("revenue")) > (5000.0)]
ESTIMATED ROWS: 1000000
1) Parquet SCAN [orders.parquet]
orders.parquetを 読み込み元としてスキャンします。
2) PROJECT 2/7 COLUMNS
- 元ファイルには 7列あるが、クエリで必要なのは 2列(user_id と revenue)だけ。
- そのため Polars は Parquetを読む段階で2列だけ読みます(列のpushdown)。
- これが projection pushdown(射影の押し込み)です。
- 不要列のI/O・デコード・メモリ確保が減ります。
3) SELECTION: [(col("revenue")) > (5000.0)]
revenue > 5000の条件を スキャン段階で適用する計画です。- これが predicate pushdown(条件の押し込み)です。
- 早い段階で行数を減らせる可能性があります。
- 特に Parquet は列ごとに統計情報(min/maxなど)を持つことが多く、条件によっては「この塊は全て条件外だから読まない」といった最適化が期待できます(条件次第)。
Unoptimized の解説
SELECT [col("user_id"), col("revenue")]
FILTER [(col("revenue")) > (5000.0)]
FROM
Parquet SCAN [orders.parquet]
PROJECT */7 COLUMNS
ESTIMATED ROWS: 1000000
これは「書いた処理を、そのまま順にやるとこうなる」という形です。
1) Parquet SCAN ... PROJECT */7 COLUMNS
- 最適化前なので、SCANは 7列全部を読む想定になっています(
*/7)。 - つまり「まず全部読みます」。
2) FILTER [(col("revenue")) > (5000.0)]
- 読み込んだあとに revenue > 5000 を適用します。
- これは遅いわけではないですが、不要な列を読んだあとにフィルタすることになり得ます。
3) SELECT [col("user_id"), col("revenue")]
- 最後に必要な2列だけ残します。
- でも、ここまでに不要な列も読んでいるので、ムダが出ます。
この差が意味すること
今回の差は、言い換えるとこうです。
- Unoptimized:
「全部読み込む → 条件で絞る → 列を減らす」 - Optimized:
「読む時点で列を減らし、読む時点で条件も適用する」
これが Polars Lazy の価値で、特に
- 列数が多いデータ
- Parquetのような列指向フォーマット
- 大規模データ
で効いてきます。
ちなみに:なぜ最適化後は FILTER/SELECT が消えている?
最適化後の表示で FILTER や SELECT が消えたように見えるのは、
- FILTER →
SELECTION:として SCAN に統合 - SELECT →
PROJECT 2/7として SCAN に統合
されているからです。
「消えた」のではなく、もっと前段に押し込まれて合体した、という理解が正しいです。
■ Streaming(メモリ効率)
result = (
lf.filter(pl.col("revenue") > 10000)
.group_by("item_id")
.agg(pl.col("revenue").sum())
.collect(streaming=True)
)
print(result)
Streamingは:
- メモリに全体を載せない
- チャンク単位で処理(サイズは数万〜数十万行程度になることが多い)
- 大規模データ向け
※高速化というより、メモリ削減が目的です。
■ SQLも書ける
ctx = pl.SQLContext(
orders=pl.scan_parquet("orders.parquet"),
users=pl.scan_parquet("users.parquet"),
)
result = ctx.execute("""
SELECT u.region, SUM(o.revenue) AS revenue_sum
FROM orders o
JOIN users u
ON o.user_id = u.user_id
GROUP BY u.region
ORDER BY revenue_sum DESC
""")
print(result.collect())
SQLで書いても内部はPolarsエンジンが最適化します。
出力結果:
shape: (5, 2)
| region | revenue_sum |
|---|---|
| str | f64 |
| ═════ | ═════════ |
| Chubu | 5.6102e9 |
| Kyushu | 5.5635e9 |
| Kanto | 5.5577e9 |
| Tohoku | 5.5373e9 |
| Kansai | 5.5274e9 |
Polarsのメリット・デメリット
メリット
- 高速(Rust + 並列)
- Lazy最適化
- 大規模データに強い
- SQLサポート
デメリット
- Pandasと完全互換ではない
- エコシステムはまだPandas優勢
- Expressionに慣れる必要あり
まとめ
Polarsは「Pandasの代替」ではなく、
設計思想が異なる次世代データ処理エンジンです。
特に:
- Lazy実行
- クエリ最適化
- Streaming
- SQL対応
が特徴です。
小〜中規模データではPandasで十分なケースも多いですが、
大規模データやパフォーマンス重視の場面ではPolarsは強力な選択肢になります。