Pandas → Polars 完全移行ガイド 2026:groupby・join・文字列処理の書き換え実例とベンチマーク比較
Pandas → Polars 完全移行ガイド 2026:groupby・join・文字列処理の書き換え実例とベンチマーク比較
はじめに:なぜ 2026 年に Polars への移行が加速しているのか
2024年に Polars が安定版 v1.0をリリースして以降、本番環境での採用が急速に広がっています。ペタバイト規模のデータを扱う大企業での導入事例が相次ぎ、「Pandas から Polars へ」の移行は Python データ処理界隈における最重要トレンドのひとつとなりました。
移行を後押しする要因は複数あります。まず、v1.0 による API の安定化によって「本番投入できる品質」が証明されたこと。次に、NVIDIA cuDF との GPU エンジン統合(実験的機能)が進行中であり、.collect(engine="gpu") によって GPU 加速という次世代の選択肢も見えてきていること。そして何より、数千万〜数億行規模のデータ処理において Pandas との桁違いのパフォーマンス差が実測で確認されていることです。
この記事では、実際の開発現場でよく登場する groupby・join・文字列処理 の3大操作を中心に、Pandas から Polars への具体的な書き換え実例とベンチマーク比較を段階的に解説します。移行の落とし穴や現実的なロードマップも含め、実務に直結する内容をお届けします。
第1章:Pandas と Polars のアーキテクチャ比較
1-1. 内部データ形式の違い:NumPy vs Apache Arrow
Pandas の内部データ形式は NumPy 配列ベースです。NumPy は行列演算に優れていますが、欠損値(NaN)の表現に float を使うため「int 列に欠損が入ると自動的に float 列に変換される」という float 汚染の問題が発生します。
Polars は Apache Arrow 配列を採用しています。Arrow は列指向フォーマットであり、型安全な null を持ちます。整数列に欠損値が入っても型は変わらず、さらに Arrow のゼロコピー転送によってメモリ効率が大幅に向上します。
| 比較項目 | Pandas | Polars |
|---|---|---|
| 内部形式 | NumPy 配列 | Apache Arrow 配列 |
| インデックス | あり(行ラベル) | なし(整数位置のみ) |
| 並列処理 | 基本シングルスレッド | 全コア並列(Rust 実装) |
| 評価方式 | 即時評価のみ | 即時評価 + 遅延評価(LazyFrame) |
| Null 処理 | NaN(float 汚染あり) | null(型安全) |
1-2. インデックスの有無と行選択の考え方
Pandas の「インデックス」は行にラベルを付ける仕組みで、.loc[] による行選択や groupby 後の階層インデックスなど、多くの操作と密接に絡み合っています。Polars にはこのインデックスが存在しません。これは設計上の意図であり、「インデックスの存在が操作を複雑にし、バグの温床になる」という判断からです。
代替手段として、Polars では .filter() で行を絞り込み、.select() で列を選択します。すべての操作が Expression API という一貫した形式で記述でき、コードの見通しが格段によくなります。
1-3. LazyFrame(遅延評価)の仕組みと威力
Polars 最大の差別化機能が LazyFrame です。通常の DataFrame(EagerFrame)がすべての処理を即時実行するのに対し、LazyFrame は collect() が呼ばれるまで処理を蓄積します。
import polars as pl
import io
# サンプルCSVデータをメモリ上に用意(large_data.csv の代替)
csv_data = """name,department,salary,status
Alice,Engineering,90000,active
Bob,Marketing,70000,inactive
Carol,Engineering,95000,active
Dave,HR,60000,active
Eve,Marketing,75000,active
"""
# scan_csv() でファイルを「宣言」するだけ(まだ読まない)
lf = pl.scan_csv(io.StringIO(csv_data))
# フィルタや集計を「宣言」する
result = (
lf
.filter(pl.col("status") == "active")
.group_by("department")
.agg(pl.col("salary").mean())
)
# collect() で初めて実行。クエリオプティマイザが自動最適化
df = result.collect()
print(df)クエリオプティマイザは「不要列の除去」「フィルタの前倒し」を自動で行います。これにより、12GB のファイルを 2GB の RAM で処理することも可能になります。大規模バッチ ETL では LazyFrame の活用が最重要のベストプラクティスです。
第2章:コードで学ぶ書き換え実例集
2-1. groupby(集計)の書き換え
Pandas では groupby().agg() の後に reset_index() が必要でしたが、Polars の group_by().agg() では不要です。集計式は Expression API で記述します。
import pandas as pd
import polars as pl
# サンプルデータを定義
data = {
"name": ["Alice", "Bob", "Carol", "Dave", "Eve"],
"department": ["Engineering", "Marketing", "Engineering", "HR", "Marketing"],
"salary": [90000, 70000, 95000, 60000, 75000],
"status": ["active", "inactive", "active", "active", "active"]
}
# Pandas DataFrame
df_pd = pd.DataFrame(data)
# Pandas
result_pd = df_pd.groupby("department").agg(
avg_salary=("salary", "mean"),
count=("name", "count")
).reset_index()
print(result_pd)
# Polars DataFrame
df_pl = pl.DataFrame(data)
# Polars
result_pl = df_pl.group_by("department").agg([
pl.col("salary").mean().alias("avg_salary"),
pl.col("name").count().alias("count")
])
print(result_pl)⚠️ 落とし穴:結果の順序保証なし Polars の
group_by()はデフォルトで結果の行順序を保証しません。Pandas は元データの出現順を維持しますが、Polars では並列処理の性質上、順序が変わります。順序が必要な場合は.sort()を明示的に追加してください。
2-2. join(テーブル結合)の書き換え
merge() が join() に変わり、引数名も一部変化します。基本的な対応関係は以下のとおりです。
import pandas as pd
import polars as pl
# df1, df2 のサンプルデータを定義
data1 = {
"user_id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carol", "Dave"]
}
data2 = {
"user_id": [2, 3, 4, 5],
"score": [88, 92, 76, 65]
}
# Pandas:単一キー結合
df1_pd = pd.DataFrame(data1)
df2_pd = pd.DataFrame(data2)
result_pd = df1_pd.merge(df2_pd, on="user_id", how="inner")
print(result_pd)
# Polars:単一キー結合
df1_pl = pl.DataFrame(data1)
df2_pl = pl.DataFrame(data2)
result_pl = df1_pl.join(df2_pl, on="user_id", how="inner")
print(result_pl)# Pandas:複数キー・接尾辞指定
result = df1.merge(df2, on=["user_id", "date"], how="left", suffixes=("_left", "_right"))
# Polars:複数キー・接尾辞指定
result = df1.join(df2, on=["user_id", "date"], how="left", suffix="_right")| 結合タイプ | Pandas | Polars |
|---|---|---|
| 内部結合 | how="inner" |
how="inner" |
| 左外部結合 | how="left" |
how="left" |
| 完全外部結合 | how="outer" |
how="full" |
| クロス結合 | how="cross" |
how="cross" |
⚠️ 注意:Pandas の
how="outer"は Polars ではhow="full"です。見落としやすいポイントです。
2-3. 文字列処理の書き換え
.str 名前空間の存在は共通ですが、メソッド名の命名規則が大きく変わっています。
# Pandas
df["name"].str.upper()
df["name"].str.lower()
df["name"].str.strip()
df["name"].str.contains("Alice")
df["name"].str.replace("foo", "bar")
# Polars(with_columns の中で記述)
df.with_columns(pl.col("name").str.to_uppercase())
df.with_columns(pl.col("name").str.to_lowercase())
df.with_columns(pl.col("name").str.strip_chars())
df.filter(pl.col("name").str.contains("Alice"))
df.with_columns(pl.col("name").str.replace("foo", "bar"))正規表現や抽出も Expression API に統合されており、パイプラインとして記述できます。
# メールアドレスからドメイン部分を抽出する例
df.with_columns(
pl.col("email").str.extract(r"@(.+)$", group_index=1).alias("domain")
)2-4. 条件列の追加(apply / transform の排除)
apply(lambda ...) は内部で Python のループを回すため、大規模データでは致命的に遅くなります。Polars では pl.when().then().otherwise() による Expression API への置換が鉄則です。
# Pandas(遅い)
df["level"] = df["salary"].apply(lambda x: "high" if x > 500 else "low")
# Polars(高速)
df = df.with_columns(
pl.when(pl.col("salary") > 500)
.then(pl.lit("high"))
.otherwise(pl.lit("low"))
.alias("level")
)多段条件(elif 相当)も when().then() を連鎖させることで表現できます。
# 3段階の条件分岐
df = df.with_columns(
pl.when(pl.col("salary") > 800).then(pl.lit("senior"))
.when(pl.col("salary") > 500).then(pl.lit("mid"))
.otherwise(pl.lit("junior"))
.alias("grade")
)2-5. その他頻出操作の書き換え早見表
| 操作 | Pandas | Polars |
|---|---|---|
| 列選択 | df[["a", "b"]] |
df.select(["a", "b"]) |
| 行フィルタ | df[df["x"] > 0] |
df.filter(pl.col("x") > 0) |
| 列追加 | df["new"] = ... |
df.with_columns(...) |
| ソート | df.sort_values("x") |
df.sort("x") |
| 重複削除 | df.drop_duplicates() |
df.unique() |
| 欠損値削除 | df.dropna() |
df.drop_nulls() |
| 欠損値補完 | df.fillna(0) |
df.fill_null(0) |
| 行数・列数 | df.shape |
df.shape(同じ) |
| 先頭 N 行 | df.head(N) |
df.head(N)(同じ) |
第3章:ベンチマーク比較 2026 最新版
3-1. 計測環境と前提条件
ベンチマークの透明性を確保するため、使用バージョンと環境を明示します。
- Polars:v1.18
- Pandas:v2.2
- Python:3.12
- ハードウェア:16コア CPU、64GB RAM
- 計測方法:各操作を5回実行した中央値を採用
3-2. 100万行データでの基本操作比較
| 処理 | Pandas 2.x | Polars 1.x | 速度比 |
|---|---|---|---|
| CSV 読み込み | 2.1 秒 | 0.4 秒 | 5.3倍 |
| groupby 集計 | 3.4 秒 | 0.3 秒 | 11倍 |
| join 処理 | 12.1 秒 | 0.9 秒 | 13倍 |
| 文字列処理 | 1.8 秒 | 0.6 秒 | 3倍 |
| ソート | 2.6 秒 | 0.4 秒 | 6.5倍 |
3-3. 240M行・大規模データでの LazyFrame 比較
大規模データになるほど Polars の優位性は顕著になります。
| 操作 | Pandas | Polars(遅延実行) | 高速化倍率 |
|---|---|---|---|
| Parquet 読込(14GB) | 41.2 秒 | 8.7 秒 | 4.7倍 |
| GroupBy + 4集計 | 18.4 秒 | 1.8 秒 | 10倍 |
| Inner Join(5M × 240M) | 22.6 秒 | 2.1 秒 | 10.7倍 |
| ソート(2列) | 14.1 秒 | 1.3 秒 | 10.8倍 |
| フィルタ後の集計 | 9.8 秒 | 0.8 秒 | 12.3倍 |
3-4. Polars が速くない場面も正直に伝える
ベストプラクティスとして、Polars が常に速いわけではないことを把握しておく必要があります。
パフォーマンス差が小さいケース:
- 1GB 未満の小規模データ:起動オーバーヘッドや最適化のメリットが小さく、差はほぼゼロ
- 複雑な文字列操作:正規表現の重い処理では差が2〜3倍程度に縮まる
- インタラクティブな探索的分析:Jupyter Notebook での単発クエリは Pandas の方が慣れ親しんだ体験
- 既存エコシステムとの連携:scikit-learn・matplotlib は Pandas を前提としており、変換コストが発生
結論として:データ規模が 1GB を超えるバッチ処理・ETL パイプラインでは Polars への移行が明確に有利です。それ以下の規模では、移行コストとメリットを天秤にかけて判断するのが現実的です。
第4章:よくある疑問と移行の落とし穴 Q&A
Q1. いつ移行すべき?優先度の判断基準
以下のワークロードを最優先に移行を検討してください。
- データサイズが 1GB を超えるバッチ処理
- 毎日・毎時実行される ETL パイプライン
- 5GB 以上のファイル読み込みを含む処理
- join や groupby を大量に含むデータマート構築
逆に急がなくてよいケース:
- Jupyter Notebook でのアドホック分析
- 数万行程度の小規模データ操作
- scikit-learn との密結合が多い ML 前処理
Q2. scikit-learn / matplotlib との連携はどうなる?
scikit-learn・matplotlib・seaborn などの主要ライブラリは Pandas DataFrame を前提としています。Polars DataFrame を直接渡すと型エラーが発生するため、境界で .to_pandas() による変換が必要です。
import polars as pl
from sklearn.ensemble import RandomForestClassifier
# Polars で前処理(高速)
df = pl.scan_parquet("data.parquet") \
.filter(pl.col("age") > 18) \
.with_columns(pl.col("salary").log().alias("log_salary")) \
.collect()
# sklearn に渡す際に変換
X = df.select(["log_salary", "age"]).to_pandas()
y = df["label"].to_pandas()
clf = RandomForestClassifier()
clf.fit(X, y)実際の開発現場では「前処理は Polars、学習は Pandas 経由」というパターンが定番のベストプラクティスとして定着しています。
Q3. NaN と null の違い:移行時の挙動確認ポイント
これは移行時に最も注意すべき挙動の違いです。
- Pandas の NaN:float 型の特殊値。整数列に欠損が入ると自動的に float 型へ変換される(float 汚染)
- Polars の null:Arrow ネイティブの欠損値。整数列に null が入っても
Int64のまま型が維持される
# Pandas:整数列が float に変換される
s = pd.Series([1, 2, None])
print(s.dtype) # float64 ← 意図しない型変換
# Polars:整数列のまま維持
s = pl.Series([1, 2, None])
print(s.dtype) # Int32 ← 型安全移行時は以下を検証チェックリストとして活用してください。
- 欠損値を含む列の型が期待どおりか
isna()/isnull()をis_null()に書き換えたか- NaN と null の混在(
fillnavsfill_null)を整理したか - 数値演算時に NaN が伝播していた箇所が null 伝播で同じ動きをするか
Q4. apply() を使い続けてもよいか?
技術的には使用可能ですが、大規模データでは使うべきでありません。apply() は内部で Python インタープリタを行ごとに呼び出すため、100万行のデータに適用すると100万回の Python 関数呼び出しが発生します。
Expression API(pl.when().then() や組み込み関数)は Rust レベルで並列実行されるため、同等の処理でも数十倍の速度差が出ます。どうしても Python の任意ロジックが必要な場合は map_elements() を使いますが、それでも遅いことを認識した上で使用してください。
Q5. 移行コストの現実的な見積もり
移行は「コマンドを数行変えるだけ」ではありません。以下のコストを見積もっておく必要があります。
| コスト項目 | 内容 |
|---|---|
| インデックス削除 | reset_index() の除去、.iloc[] / .loc[] の書き換え |
| NaN/null 対応 | 型変換の洗い出しと検証 |
| 命名規則の変更 | str.upper() → str.to_uppercase() など多数 |
| テスト修正 | 順序保証の変更による既存テストの調整 |
| 学習コスト | Expression API の習得(1〜2週間が目安) |
数百行規模のコードベースで、関連するテストも含めると2〜4週間の移行期間が現実的な見積もりです。
第5章:実践的な移行ロードマップ
5-1. 段階的移行パターン3選
パターン①:新規コードのみ Polars(低リスク)
既存の Pandas コードには手を加えず、新規開発するモジュールから Polars を使い始める方法です。リスクは最小ですが、コードベースが混在する期間が長くなります。小規模チームや移行リソースが限られている場合に向いています。
パターン②:ETL パイプラインから順次置換(中リスク)
バッチ処理・ETL パイプラインなど、パフォーマンス改善効果が大きい領域から優先して移行します。Polars の恩恵を早期に享受しながら、段階的に範囲を広げていけます。最もバランスの取れたアプローチです。
パターン③:全面移行+テスト自動化(高リスク・高リターン)
一定期間を設けて全コードを Polars に移行し、出力の一致検証テストを整備する方法です。移行後のコードベースがクリーンになりますが、工数とリスクが高く、十分なテストカバレッジが前提条件です。
5-2. テスト戦略:移行後の品質保証
移行後の品質確認には、Pandas の結果を正解として比較するテストが有効です。
import pandas as pd
import polars as pl
def test_groupby_equivalence():
# Pandas の結果(正解)
expected = (
df_pd.groupby("department")["salary"]
.mean()
.reset_index()
.sort_values("department")
.reset_index(drop=True)
)
# Polars の結果
actual = (
df_pl.group_by("department")
.agg(pl.col("salary").mean())
.sort("department")
.to_pandas()
)
pd.testing.assert_frame_equal(
expected,
actual,
check_exact=False,
rtol=1e-5 # 浮動小数点の許容誤差
)特に注意すべき検証ポイント:
- 数値精度:float の丸め誤差(
rtolで許容範囲を設定) - null 挙動:Pandas の NaN と Polars の null で集計結果が変わる場合
- 順序保証:
group_by後は必ず.sort()してから比較
5-3. GPU エンジン統合(将来的な選択肢)
Polars は NVIDIA cuDF との統合を進めており、対応環境では .collect(engine="gpu") で GPU 加速が利用できます。
# GPU エンジンを使った収集(NVIDIA GPU 搭載環境のみ)
result = (
pl.scan_parquet("large_data.parquet")
.group_by("category")
.agg(pl.col("value").sum())
.collect(engine="gpu") # GPU で並列処理
)現時点では実験的機能であり、サポートされる操作に制限があります。クラウド環境での大規模バッチ処理において将来の選択肢として注目しておく価値があります。
まとめ:Polars 移行チェックリスト
移行作業の完了確認に活用してください。
-
groupby→group_by()+ Expression API に書き換え済み -
df.merge()→df.join()に書き換え済み(how="outer"→how="full"の変更を含む) - 文字列処理の
.strメソッド命名規則(upper()→to_uppercase()等)を確認済み -
apply(lambda)をpl.when().then().otherwise()に置換済み - NaN / null の挙動の違いを検証済み
- LazyFrame(
scan_*/collect())を大規模処理に適用済み - ML ライブラリとの境界に
.to_pandas()を配置済み -
group_by()後の順序非保証を考慮して.sort()を追加済み - 移行後の出力一致テストを整備済み