データ処理でpandas使ってるなら、Polarsに変えてみない?
はじめに
「pandasで困ってないし」
自分もそう思ってました。EDA(探索的データ分析)でもETL(データの抽出・変換・ロード)でもpandas。検索したらヒットするし、エコシステムとしてデカい。それで回っていました。
でもETL処理でPolarsに変えてみたら、少なくともローデータの取り込み処理に関しては戻る気が起きなくなりました(いや、戻れないことはないんですけどね)。
この記事は、medallionアーキテクチャのデータ基盤で、ローデータをBronze層に取り込む処理をpandasからPolarsに移行した話です。
「Polarsとは何か」みたいな教科書的な話は最小限にします。実際に計測した数字と、移行してみてぶっちゃけどうだったかを書きます。
自分の環境での計測結果であり、全てのケースに当てはまるわけではありません。データの形状や処理内容によって結果は変わります。
生成AIと共同で執筆しています。ベンチマークは自分で回しています。
自分のデータ基盤の構成
ざっくりこんな感じです(詳細は伏せます)。
- データソース: Cloud SQL、GCS、他にも複数あります
- オーケストレーション: Cloud Composer(Airflow)
- DWH: BigQuery
- 変換: dbt + Python
- 実行基盤: Cloud Run Jobs等
bronze以降の変換はdbtでやっています。Polarsの出番はその手前——ローデータ(CSV、JSONなど)を読み込んで、型を整えて、不要なレコードを落として、ParquetにしてBronze層やデータセットに入れるところとか。いわゆるデータロード・取り込み処理です。この部分をpandasでやっていました。
pandasで「困ってなかった」はずなのに
正直、pandasで「動いて」はいました。でも振り返ると、地味にストレスはあったんですよね。
- 大きめのデータで
MemoryError。朝起きたらジョブ落ちてた、みたいな(まぁこれはデータ量に対してスペック足りてなかっただけ説) -
pd.read_csvのlow_memory警告。型が勝手に混在してて、下流で気づいて絶望 - Cloud Run Jobsのメモリを「とりあえず1GB」にしてる。根拠は? → ない笑
「困ってない」じゃなくて「慣れてた」だけだったんじゃないか、と移行してから思うようになりました。
自分で計測してみた
公式ベンチマーク(TPC-H: データベースの分析性能を測る標準的なベンチマーク)の引用じゃ面白くないので、自分の処理パターンを再現して計測しました。
処理内容
ローデータをBronzeに入れるときによくやるパターンを、2つに分けて計測しました。
パターンA: シンプルなデータロード(実際のBronze loading処理に近い)
- CSV読み込み — 300万行の注文データ(order_id, 日付, カテゴリ, 地域, 金額, 数量)
-
型変換 — 文字列の日付を
Date型に - nullフィルタ — regionが空のレコードを除去
- Parquet書き出し
パターンB: データロード+集計(たまにやる、集計込みの処理)
1〜3はパターンAと同じ + group_by集計(カテゴリ×地域ごとの売上合計・件数)
パターンAもBも、どちらもCloud SQLからエクスポートしたCSVを整形してParquetにする、dbtに渡す前のデータロード処理のイメージです。
結果
環境: Mac mini (Apple M4 / 24GB)、Python 3.12、Polars 1.38.1、pandas 3.0.1
パターンA: シンプルなデータロード
| 時間 | メモリ(Python側) | 速度比 | |
|---|---|---|---|
| pandas | 4.43秒 | 432.7MB | 1.0x |
| Polars (eager) | 0.19秒 | - | 約23x |
| Polars (lazy) | 0.14秒 | - | 約32x |
パターンB: データロード+集計
| 時間 | メモリ(Python側) | 速度比 | |
|---|---|---|---|
| pandas | 4.22秒 | 432.5MB | 1.0x |
| Polars (eager) | 0.14秒 | - | 約31x |
| Polars (lazy) | 0.09秒 | - | 約47x |
※ Polarsのメモリはtracemalloc(Python側のみ計測)のため参考値。PolarsはRust側で処理するのでPython側はほぼゼロになる一方、pandasはPython側で全て処理するので432MBがそのまま見えます。
シンプルなLoad処理でも23〜32倍。group_by集計が入ると遅延評価の最適化が効いて47倍まで伸びます。
ただし、これは比較的シンプルなパターンでの結果です。複雑なカスタム処理(.apply()多用とか)だと差は縮まると思います。逆に、もっとデータが大きいケースや結合が絡む処理だと、Polarsの遅延評価がさらに効いてくるはず。
コード比較
実際のBronze loadingに近いパターンAで比較します(集計込みのパターンBは末尾の再現用スクリプトを参照)。
pandas:
df = pd.read_csv("bronze_orders.csv")
df["order_date"] = pd.to_datetime(df["order_date"])
df["region"] = df["region"].replace("", pd.NA)
df = df.dropna(subset=["region"])
df.to_parquet("bronze.parquet", index=False)
Polars (lazy):
df = (
pl.scan_csv("bronze_orders.csv")
.with_columns(pl.col("order_date").str.to_date("%Y-%m-%d"))
.with_columns(
pl.when(pl.col("region") == "")
.then(None)
.otherwise(pl.col("region"))
.alias("region")
)
.drop_nulls(subset=["region"])
.collect()
)
df.write_parquet("bronze.parquet")
pandasは1行ずつDataFrameを書き換えていくスタイル。Polarsはscan→変換→collectの一連のチェーンで書きます。Polarsの方が少し長いけど、「何を読んで、何を変換して、何を除外するか」が上から下に流れるので、処理の全体像が掴みやすいんですよね。
null処理の書き方は好みが分かれそう。pandasのreplace("", pd.NA) + dropnaはシンプル。Polarsのwhen().then(None).otherwise()は冗長に見えるけど、明示的ではあります。
お金の話になる
速いのは嬉しいけど、上司やBizサイドに「速くなりました!」だけだと「ふーん」で終わるんですよね(データ基盤あるある。伝わらない辛さはまた別で書きたい...)。
でもこれ、コストの話に繋げられるんですよ。
Cloud Run Jobs
Cloud Run Jobsは秒単位課金です。
- pandas: 4.43秒 × vCPU単価
- Polars: 0.14秒 × vCPU単価
単純計算だと約30分の1。もちろん実際にはコンテナ起動のオーバーヘッドとかあるので、ジョブ全体がそのまま30倍速くなるわけじゃないです。でも、ETL処理が複数ジョブあって日次で動いていたら、積もり積もって効いてきます。
メモリ
pandasで432MB使ってた処理が、Polarsなら小さいインスタンスで済む可能性がある。Cloud Run JobsもCloud Composerのworkerも、メモリスペックを下げられれば安くなります。
「Polarsに変えました」→「ジョブが速くなりました」→「インスタンスのスペック下げました」→「月額○円削減」
この流れで話せると、データ基盤の投資対効果を示すのが苦手な人(自分です)でも説明しやすいかな、と。ただ、実際にどれくらい削減できるかは処理の数や規模次第なので、自分の環境で試してみてほしいです。
pandasの方がいい場面、正直ある
ここ大事だと思うので、正直に書きます。Polarsに変えて全部ハッピーかというと、そうでもないです(汗)
EDA(探索的分析)
Jupyter Notebookでサクッと試すときは、まだpandasの方が楽です。df.describe()、df.plot()、scikit-learnとの連携。このあたりのエコシステムはpandasが圧倒的に強い。自分もEDAはpandas使ってます。
BigQueryクライアント
bigquery.Client().query(...).to_dataframe()はpandasのDataFrameを返します。Polarsで受けたいならpl.from_pandas()で変換が必要です。地味にめんどくさい。Google Cloud周りのライブラリはpandas前提が多いので、GCP中心のデータ基盤だとこの変換がちょいちょい発生します。
複雑な行単位のロジック
ここがPolarsの一番しんどいところだと思っています。
pandasなら.apply()に普通のPython関数を渡せば終わる処理が、Polarsだと組み込みの式で書き直す必要があります。例えば:
# pandas: 複数列を見て条件分岐するカスタムロジック
def categorize(row):
if row["amount"] > 10000 and row["region"] == "tokyo":
return "high_value_tokyo"
elif row["amount"] > 10000:
return "high_value_other"
else:
return "normal"
df["segment"] = df.apply(categorize, axis=1)
Polarsだとこうなります:
# Polars: pl.when().then().otherwise() のチェーン
df = df.with_columns(
pl.when((pl.col("amount") > 10000) & (pl.col("region") == "tokyo"))
.then(pl.lit("high_value_tokyo"))
.when(pl.col("amount") > 10000)
.then(pl.lit("high_value_other"))
.otherwise(pl.lit("normal"))
.alias("segment")
)
この程度なら読めるけど、条件が5個、10個になるとwhen().then()が地獄になります。map_elements(Polarsの.apply()相当)を使えば書けるけど、Python側に戻るのでPolarsの速さの恩恵がなくなります。
あと、前の行の結果に依存する処理(条件付き累積計算とか)もPolarsは苦手です。pandasのiterrows()やshift()+cumsum()で書くような処理は、Polarsの式ベースのAPIだと発想の転換が必要です。
チームの学習コスト
これは無視できないです。自分一人ならすぐ変えられるけど、チームで使うとなると「なんでpandasじゃダメなの?」から説明が必要です。pandasを書ける人は多いけど、Polarsを書ける人はまだ少ないです。
LLMの時代に、pandasの優位性はどこまで残るのか
上で書いたpandasの強み、2026年の今だとかなり揺らいでいると思っています。
「情報が多い」はもう優位じゃない
pandasの最大の武器は、Stack OverflowやQiitaの情報量でした。「困ったら検索すれば出てくる」。Polarsはそれがない。これが移行を躊躇する一番の理由だったと思います。
でもこれ、LLMで無効化されつつあるんですよね。
自分の移行作業を振り返ると、Polarsのコードの大半はLLM(Claude Code)に書かせました。ただし「pandasのコードを貼って、Polarsに書き換えて」で終わりではないです。LLMの出力を目で読んで正しさを確認するのは限界があるので、テストで担保することを重視しました。
やったことは:
- まず計画: どのスクリプトから移行するか、依存関係を整理。簡単なものから着手
- テスト設計: 移行前のpandasスクリプトの出力を「正解」として保存。行数、カラム名、型、集計値のサンプルをテストケースにする
- LLMで書き換え: 既存のpandasコードを貼って「Polarsに書き換えて」
- テストで検証: Polars版の出力がpandas版と一致するか、テストで自動チェック
人間の目でコードを読み比べるだけだと、微妙なロジックの変化を見落とす。特にpandasとPolarsで型が微妙に違うケースは要注意です。例えばpandasのint64がPolarsだとInt64(nullable)になったり、日付の型が異なったり。テストで出力の値だけでなく型も検証するようにしておくと安心です。
あともう一つ、LLMで移行して気づいた強みがあって、既存のpandasコードの理解もLLMに任せられるんですよね。他の人が書いたpandasスクリプトって、正直読むのしんどいじゃないですか。変数名が雑だったり、.apply()の中で何やってるか追うのに時間かかったり。LLMに「このpandasコード、何やってるか説明して」→「Polarsに書き換えて」の2ステップで、既存コードの理解とPolarsへの変換を同時にやれます。ドキュメントがないレガシーコードの移行では、これがかなり効きました。
この流れで移行しました。ゼロから自分でpandasのコードを読み解いて、さらにPolarsのドキュメントを読んで書き直すのと比べたら、圧倒的に楽です。
もちろんLLMの出力が常に正しいわけではないし、PolarsのバージョンアップでAPIが変わることもあります。でも「書き方がわからないからpandasのまま」は、もう移行しない理由として弱い。
学習コストも言い訳にならなくなってきた
「チームが覚え直すコストが...」もよく聞く理由です。自分もそう思ってました。
でもvibe codingの時代に、ライブラリの書き方を暗記する重要性ってどこまであるんでしょうか。LLMにコードを書かせて、レビューで正しさを判断する。そのスタイルなら、pandas → Polarsの書き換えは障壁になりません。実際、自分のチームでも「Polars書いたことない」メンバーがClaude Codeで普通にPolarsのコードを書いて動かしてます。
ただし、Polarsの考え方——遅延評価、式ベースのAPI——を理解していないとレビューができないので、「LLMがあるから何も学ばなくていい」ではないです。概念の理解は必要。でも構文の暗記は不要になりました。
「pandasは情報が多い」「チームが慣れている」——移行を止めていた2大理由が、LLMの時代に急速に弱くなっている。 残る判断基準は、速度・コスト・型安全性みたいな、技術的な優劣だけになりつつあります。そこで勝負すると、ETL/ELTではPolarsに分があるんですよね。
移行のやり方
全部一気に変えるのはおすすめしないです(自分もやってない)。段階的にやりました。
まず簡単なところから
- CSVやParquetの読み込み・書き出し →
pd.read_csvをpl.read_csvに変えるだけ - 単純なフィルタリングや集計 → 書き換えパターンが分かりやすい
注意が必要なところ
-
groupby→group_by(アンダースコア入る) -
apply→ できるだけ組み込み式で書き直す。map_elementsは最終手段 - 日付操作は
.dtの使い方が違う - Indexがない。pandasのIndex前提で書いてたコードは考え直す必要がある...
両方使う場面
pl.from_pandas() / df.to_pandas()で相互変換できます。BigQueryクライアントの戻り値をPolarsに変換とか、scikit-learnに食わせる前にpandasに戻すとか。無理に100% Polarsにしなくていいです。
まとめ
- ローデータのロード処理でpandas比23〜32倍、集計込みで31〜47倍の速度を確認(300万行、自分の処理パターンで計測。処理内容によって差は変わります)
- 速度だけじゃなく、Cloud Run Jobsの実行時間やインスタンスサイズを通じてコスト削減にも繋がる可能性があります
- pandasの優位だった「情報量」は、LLMの普及で以前ほどのアドバンテージではなくなってきました
- EDAやエコシステム連携ではpandasも現役。使い分けでOK
- 「困ってない」は「慣れてるだけ」かもしれない。一回試す価値はあります!
「困ってなくても移行する価値はあった」というのが正直な感想です。EDAはpandas、dbt以降の変換はSQL。でもその手前のローデータの取り込み・整形処理は、Polarsにして良かったと思ってます。
Polarsのインストールはuv add polars(またはpip install polars)だけです。試してみて合わなかったら戻せばいい。
もし同じように移行した人、逆に「pandasで十分だよ」って人がいたら、コメントで教えてもらえると嬉しいです。
再現用スクリプト
この記事のベンチマークは以下のスクリプトで再現できます。
ベンチマークスクリプト(クリックで展開)
"""
pandas vs Polars ベンチマーク
パターンA: シンプルなデータロード / パターンB: ロード+集計
uv add pandas polars pyarrow # or: pip install pandas polars pyarrow
"""
import time, os, csv, random, tracemalloc
from datetime import datetime, timedelta
N_ROWS = 3_000_000
CATEGORIES = ["electronics", "clothing", "food", "books", "toys", "sports", "home", "beauty"]
REGIONS = ["tokyo", "osaka", "nagoya", "fukuoka", "sapporo", None]
def generate_csv(path):
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
base_date = datetime(2024, 1, 1)
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["order_id", "order_date", "category", "region", "amount", "quantity"])
for i in range(N_ROWS):
d = base_date + timedelta(days=random.randint(0, 364))
r = random.choice(REGIONS)
w.writerow([f"ORD-{i:08d}", d.strftime("%Y-%m-%d"),
random.choice(CATEGORIES), r if r else "",
round(random.uniform(100, 50000), 2), random.randint(1, 20)])
def _measure(fn):
tracemalloc.start()
t0 = time.time()
result = fn()
elapsed = time.time() - t0
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
tracemalloc.stop()
return elapsed, peak, result
# パターンA: シンプルなデータロード
def pandas_load(p):
import pandas as pd
def fn():
df = pd.read_csv(p)
df["order_date"] = pd.to_datetime(df["order_date"])
df["region"] = df["region"].replace("", pd.NA)
df = df.dropna(subset=["region"])
df.to_parquet("out_pd.parquet", index=False)
return len(df)
return _measure(fn)
def polars_load(p):
import polars as pl
def fn():
df = (pl.scan_csv(p)
.with_columns(pl.col("order_date").str.to_date("%Y-%m-%d"))
.with_columns(pl.when(pl.col("region") == "").then(None)
.otherwise(pl.col("region")).alias("region"))
.drop_nulls(subset=["region"]).collect())
df.write_parquet("out_pl.parquet")
return len(df)
return _measure(fn)
# パターンB: ロード+group_by集計
def pandas_groupby(p):
import pandas as pd
def fn():
df = pd.read_csv(p)
df["order_date"] = pd.to_datetime(df["order_date"])
df["region"] = df["region"].replace("", pd.NA)
df = df.dropna(subset=["region"])
result = df.groupby(["category", "region"]).agg(
total_amount=("amount", "sum"), total_quantity=("quantity", "sum"),
order_count=("order_id", "count")).reset_index()
result.to_parquet("out_pd_gb.parquet", index=False)
return len(result)
return _measure(fn)
def polars_groupby(p):
import polars as pl
def fn():
result = (pl.scan_csv(p)
.with_columns(pl.col("order_date").str.to_date("%Y-%m-%d"))
.with_columns(pl.when(pl.col("region") == "").then(None)
.otherwise(pl.col("region")).alias("region"))
.drop_nulls(subset=["region"])
.group_by(["category", "region"]).agg(
pl.col("amount").sum().alias("total_amount"),
pl.col("quantity").sum().alias("total_quantity"),
pl.col("order_id").count().alias("order_count")).collect())
result.write_parquet("out_pl_gb.parquet")
return len(result)
return _measure(fn)
if __name__ == "__main__":
csv_path = "bench_data.csv"
if not os.path.exists(csv_path):
print("ダミーデータ生成中...")
generate_csv(csv_path)
for name, pd_fn, pl_fn in [
("A: シンプルなLoad", pandas_load, polars_load),
("B: Load + group_by", pandas_groupby, polars_groupby),
]:
print(f"\n--- パターン{name} ---")
pt, pm, _ = pd_fn(csv_path)
lt, lm, _ = pl_fn(csv_path)
print(f" pandas: {pt:.2f}s, {pm:.1f}MB")
print(f" Polars(lazy): {lt:.2f}s, {lm:.1f}MB, {pt/lt:.1f}x")