Polars入門ガイド:pandasユーザーが今すぐ始める次世代データフレーム

Rustベースの次世代データフレームPolarsをpandasユーザー向けに徹底解説。Expression API、遅延評価、ストリーミング、GPU対応からETLパイプライン構築まで、コード例で実践的に学べます。

はじめに:なぜ今Polarsが注目されているのか

データサイエンスの世界でpandasを使ったことがない人はほとんどいないですよね。2008年に登場してからずっと、Pythonでのデータフレームといえばpandasが事実上の標準でした。

でも正直なところ、2026年の今、データサイエンティストの間でかなり話題になっているライブラリがあるんです。それがPolars

Polarsは、Rustで書かれた次世代のデータフレームライブラリで、マルチスレッド処理とApache Arrowカラムナフォーマットをベースにした設計により、pandasでは実現できなかったレベルのパフォーマンスを提供します。「pandasの5〜20倍速い」なんて話もよく聞きますし、実際にベンチマークでもそれが裏付けられています。個人的にも初めてPolarsのスピードを体感したときは、ちょっと感動しました。

当サイトでは以前、Pandas 3.0の新機能(Copy-on-Write、str dtype、pd.col()など)について詳しく紹介しましたが、今回はそれを補完する形で、pandasユーザーがPolarsを効率的に学ぶための完全ガイドをお届けします。pandasを完全に捨てる必要はありません。ただ、Polarsという選択肢を持っておくことは、2026年のデータサイエンティストにとって大きな武器になるはずです。

Polarsとは何か

コアアーキテクチャ

Polarsは、オランダのソフトウェアエンジニアRitchie Vinkが2020年に開発を開始したデータフレームライブラリです。コアエンジンはRustで実装されていて、Pythonバインディングを通じてPythonから利用できます(R、Node.jsからも使えます)。内部データフォーマットにはApache Arrowのカラムナ形式を採用しており、DuckDBやSparkなどとゼロコピーでのデータ連携が可能です。

2025年にはAccelが主導するシリーズAで2,100万ドルの資金調達を行い、Polars Cloudの一般提供も開始されました。もはや個人プロジェクトじゃないんですよね。エンタープライズレベルのデータ処理基盤として急速に成長しています。

設計思想:pandasとの根本的な違い

Polarsの設計思想を理解するのは結構大事です。以下の3つのポイントを押さえておきましょう。

  • インデックスがない:pandasではインデックスが多くの混乱やバグの原因になっていましたが、Polarsにはそもそもインデックスという概念が存在しません。行の参照はすべて明示的なカラム操作で行います。
  • 厳格な型システム:pandasではobject型がなんでも受け入れてしまいましたが、Polarsは厳格な型システムを持っていて、型の不整合を早期に検出してくれます。
  • Expression APIベース:操作はすべて「式(Expression)」として表現され、メソッドチェーンで組み合わせることで複雑な変換を宣言的に書けます。

Eager vs Lazy:2つの実行モード

Polarsには2つの実行モードがあります。

  • Eager(即時実行)モード:pandasと同様に、各操作が呼び出された時点ですぐに実行されます。DataFrameオブジェクトを使います。
  • Lazy(遅延評価)モード:操作を即座に実行せず、クエリプランとして蓄積し、.collect()が呼ばれた時点で最適化されたクエリを一括実行します。LazyFrameオブジェクトを使います。

大規模データを扱うなら、Lazyモードが圧倒的におすすめです(詳しくは後述します)。

バージョン情報

2026年2月現在、Polarsの最新安定版はv1.38系です。v1.0が2024年にリリースされて以降、ほぼ毎週のペースでリリースが続いています。開発の活発さがすごい。

インストールと基本操作

インストール

Polarsのインストールはめちゃくちゃ簡単です。

# 基本インストール
pip install polars

# 追加機能付き(Excel、タイムゾーン、その他)
pip install 'polars[all]'

# GPU対応版
pip install 'polars[gpu]'

condaを使っている方はこちら。

conda install -c conda-forge polars

DataFrameの作成

まずはDataFrameの作成方法から見ていきましょう。pandasとよく似ていますが、微妙に違うところがあります。

import polars as pl

# 辞書からDataFrameを作成
df = pl.DataFrame({
    "名前": ["田中", "佐藤", "鈴木", "高橋", "伊藤"],
    "年齢": [28, 35, 42, 31, 26],
    "部署": ["営業", "開発", "開発", "マーケ", "営業"],
    "年収": [450, 620, 580, 510, 420],
})

print(df)
# shape: (5, 4)
# ┌──────┬──────┬────────┬──────┐
# │ 名前 ┆ 年齢 ┆ 部署   ┆ 年収 │
# │ ---  ┆ ---  ┆ ---    ┆ ---  │
# │ str  ┆ i64  ┆ str    ┆ i64  │
# ╞══════╪══════╪════════╪══════╡
# │ 田中 ┆ 28   ┆ 営業   ┆ 450  │
# │ 佐藤 ┆ 35   ┆ 開発   ┆ 620  │
# │ 鈴木 ┆ 42   ┆ 開発   ┆ 580  │
# │ 高橋 ┆ 31   ┆ マーケ ┆ 510  │
# │ 伊藤 ┆ 26   ┆ 営業   ┆ 420  │
# └──────┴──────┴────────┴──────┘

出力がきれいなテーブル形式になっているの、地味にうれしいですよね。各カラムの型(stri64など)が明示的に表示されるのもPolarsならではです。

基本操作:select、filter、with_columns

Polarsの基本操作は、ぶっちゃけ3つのメソッドを覚えればほとんどカバーできます。

import polars as pl

df = pl.DataFrame({
    "名前": ["田中", "佐藤", "鈴木", "高橋", "伊藤"],
    "年齢": [28, 35, 42, 31, 26],
    "部署": ["営業", "開発", "開発", "マーケ", "営業"],
    "年収": [450, 620, 580, 510, 420],
})

# select: カラムの選択
result = df.select("名前", "年収")

# filter: 行のフィルタリング
result = df.filter(pl.col("年齢") >= 30)

# with_columns: 新しいカラムの追加・既存カラムの変換
result = df.with_columns(
    (pl.col("年収") * 10000).alias("年収_円"),
    (pl.col("年齢") >= 30).alias("30歳以上"),
)

print(result)

pandasとの比較:基本操作

pandasユーザーが最初に戸惑うのは、やっぱり書き方の違いだと思います。同じ処理をpandasとPolarsで並べて見てみましょう。

# ===== pandas =====
import pandas as pd

pdf = pd.DataFrame({
    "名前": ["田中", "佐藤", "鈴木"],
    "年齢": [28, 35, 42],
    "年収": [450, 620, 580],
})

# カラム選択
pdf[["名前", "年収"]]

# フィルタリング
pdf[pdf["年齢"] >= 30]

# カラム追加
pdf["年収_円"] = pdf["年収"] * 10000


# ===== Polars =====
import polars as pl

pldf = pl.DataFrame({
    "名前": ["田中", "佐藤", "鈴木"],
    "年齢": [28, 35, 42],
    "年収": [450, 620, 580],
})

# カラム選択
pldf.select("名前", "年収")

# フィルタリング
pldf.filter(pl.col("年齢") >= 30)

# カラム追加(元のDataFrameは変更されない)
pldf.with_columns(
    (pl.col("年収") * 10000).alias("年収_円")
)

ここで押さえておきたいのが、Polarsではすべての操作が新しいDataFrameを返す(イミュータブル)という点です。pandasのinplace=True的な概念はありません。これ、最初はちょっと面倒に感じるかもしれませんが、副作用のないコードが書けるのでバグが激減しますし、並列処理も安全に行えるんです。

Expression API:Polarsの真髄

さて、ここからが本番です。Polarsを使いこなす上で最も重要なのがExpression API。これを理解するかどうかで、Polarsを「ちょっと速いpandas」として使うか、「まったく別次元のツール」として使うかが分かれます。

pl.col():すべての基本

pl.col()は、カラムを参照するための式です。pandasのdf["列名"]に相当しますが、もっと柔軟に使えます。

import polars as pl

df = pl.DataFrame({
    "a": [1, 2, 3, 4, 5],
    "b": [10, 20, 30, 40, 50],
    "c": [100, 200, 300, 400, 500],
})

# 特定カラムの参照
df.select(pl.col("a"))

# 複数カラムの参照
df.select(pl.col("a", "b"))

# 正規表現でカラムを選択
df.select(pl.col("^[ab]$"))

# すべてのカラムを参照
df.select(pl.col("*"))

# 特定の型のカラムだけ選択
df.select(pl.col(pl.Int64))

pl.lit()とpl.when().then().otherwise()

pl.lit()はリテラル値を式として埋め込むときに使います。条件分岐にはpl.when().then().otherwise()を使います。

import polars as pl

df = pl.DataFrame({
    "名前": ["田中", "佐藤", "鈴木", "高橋"],
    "点数": [85, 72, 93, 61],
})

# 条件に基づいてカラムを追加
result = df.with_columns(
    pl.when(pl.col("点数") >= 80)
      .then(pl.lit("合格"))
      .when(pl.col("点数") >= 60)
      .then(pl.lit("追試"))
      .otherwise(pl.lit("不合格"))
      .alias("結果")
)

print(result)
# shape: (4, 3)
# ┌──────┬──────┬──────┐
# │ 名前 ┆ 点数 ┆ 結果 │
# │ ---  ┆ ---  ┆ ---  │
# │ str  ┆ i64  ┆ str  │
# ╞══════╪══════╪══════╡
# │ 田中 ┆ 85   ┆ 合格 │
# │ 佐藤 ┆ 72   ┆ 追試 │
# │ 鈴木 ┆ 93   ┆ 合格 │
# │ 高橋 ┆ 61   ┆ 追試 │
# └──────┴──────┴──────┘

SQLのCASE WHENに似た構文なので、SQLに慣れている方ならすぐ馴染めるはずです。

メソッドチェーンの威力

Expression APIの真価は、メソッドチェーンで複雑な変換を読みやすく書けるところにあります。

import polars as pl

df = pl.DataFrame({
    "商品名": ["りんご", "バナナ", "みかん", "りんご", "バナナ"],
    "カテゴリ": ["果物", "果物", "果物", "果物", "果物"],
    "売上": [1200, 800, 600, 1500, 900],
    "数量": [10, 15, 8, 12, 20],
})

# メソッドチェーンで複雑な集計を一度に実行
result = df.group_by("商品名").agg(
    # 売上の合計
    pl.col("売上").sum().alias("売上合計"),
    # 売上の平均(小数点以下1桁に丸める)
    pl.col("売上").mean().round(1).alias("売上平均"),
    # 数量の合計
    pl.col("数量").sum().alias("数量合計"),
    # 取引回数
    pl.col("売上").count().alias("取引回数"),
    # 売上の最大値と最小値の差
    (pl.col("売上").max() - pl.col("売上").min()).alias("売上レンジ"),
)

print(result)

pandasでこういう複雑な集計をやろうとすると、.agg()に辞書や関数リストを渡す必要があって、正直あまり直感的じゃないですよね。Polarsならすべてが式として統一的に書けるので、コードの可読性が格段に上がります。

ウィンドウ関数

データ分析でよく使うウィンドウ関数も、Expression APIで自然に書けます。

import polars as pl

df = pl.DataFrame({
    "部署": ["営業", "営業", "開発", "開発", "開発"],
    "名前": ["田中", "伊藤", "佐藤", "鈴木", "山田"],
    "売上": [300, 250, 500, 450, 480],
})

# 部署ごとの平均売上と比較する
result = df.with_columns(
    # 部署ごとの平均
    pl.col("売上").mean().over("部署").alias("部署平均"),
    # 部署内での順位
    pl.col("売上").rank(descending=True).over("部署").alias("部署内順位"),
    # 部署内でのシェア
    (pl.col("売上") / pl.col("売上").sum().over("部署") * 100)
        .round(1)
        .alias("部署内シェア%"),
)

print(result)

pandasでいう.transform().groupby().rank()に相当する処理ですが、.over()を使った書き方のほうがずっと直感的だと感じる方は多いんじゃないでしょうか。

pandasからPolarsへの移行ガイド

ここからは実践編です。pandasユーザーが日常的にやっている操作を、Polarsではどう書くのか。一つずつ見ていきましょう。

CSV/Parquetファイルの読み書き

# ===== pandas =====
import pandas as pd

pdf = pd.read_csv("data.csv")
pdf = pd.read_parquet("data.parquet")
pdf.to_csv("output.csv", index=False)
pdf.to_parquet("output.parquet")


# ===== Polars =====
import polars as pl

# Eager読み込み
pldf = pl.read_csv("data.csv")
pldf = pl.read_parquet("data.parquet")

# Lazy読み込み(大規模データでは推奨)
lf = pl.scan_csv("data.csv")
lf = pl.scan_parquet("data.parquet")

# 書き出し
pldf.write_csv("output.csv")
pldf.write_parquet("output.parquet")

scan_csv/scan_parquetはファイルを実際には読み込まず、LazyFrameとしてクエリプランを構築します。必要なカラムや行だけを読み込む最適化が自動で行われるので、大きなファイルを扱うときはこちらを使いましょう。

行のフィルタリング

# ===== pandas =====
# 単一条件
pdf[pdf["年齢"] >= 30]

# 複数条件(&演算子)
pdf[(pdf["年齢"] >= 30) & (pdf["部署"] == "開発")]

# isin
pdf[pdf["部署"].isin(["営業", "開発"])]

# 文字列フィルタ
pdf[pdf["名前"].str.contains("田")]


# ===== Polars =====
# 単一条件
pldf.filter(pl.col("年齢") >= 30)

# 複数条件(&演算子、またはカンマ区切りでAND条件)
pldf.filter(
    pl.col("年齢") >= 30,
    pl.col("部署") == "開発",
)

# isin
pldf.filter(pl.col("部署").is_in(["営業", "開発"]))

# 文字列フィルタ
pldf.filter(pl.col("名前").str.contains("田"))

Polarsの.filter()では、複数条件をカンマで区切って渡すだけで自動的にAND条件になります。これ、地味に便利です。

Group ByとAggregation

# ===== pandas =====
pdf.groupby("部署").agg({
    "年収": ["mean", "sum"],
    "年齢": "max",
})

# pandasではMultiIndexのカラムが生成されて扱いにくい...


# ===== Polars =====
pldf.group_by("部署").agg(
    pl.col("年収").mean().alias("年収_平均"),
    pl.col("年収").sum().alias("年収_合計"),
    pl.col("年齢").max().alias("最高年齢"),
)

# フラットなカラム名でスッキリ!

pandasのgroupby().agg()で返ってくるMultiIndexのカラム名に苦しんだ経験、ありませんか?(私は何度もあります。)Polarsでは.alias()で明示的に名前をつけるので、結果がフラットで扱いやすいんです。

Join操作

# ===== pandas =====
result = pd.merge(df1, df2, on="user_id", how="left")
result = pd.merge(df1, df2, left_on="id", right_on="user_id", how="inner")


# ===== Polars =====
result = df1.join(df2, on="user_id", how="left")
result = df1.join(df2, left_on="id", right_on="user_id", how="inner")

Join操作の構文はpandasとPolarsでかなり似ています。ただし、Polarsのjoinは内部で並列処理されるため、大きなデータセットでは大幅に速くなります。

欠損値の処理

ここはpandasユーザーが最も注意すべきポイントの一つです。結構ハマりやすいところなので、丁寧に見ていきます。

# ===== pandas =====
# pandasではNaN(float)とNone(Python)が混在する
pdf["値"].isna()
pdf["値"].fillna(0)
pdf.dropna(subset=["値"])


# ===== Polars =====
# Polarsではnull(欠損値)とNaN(数値の非数)が明確に区別される
pldf.select(pl.col("値").is_null())    # null判定
pldf.select(pl.col("値").is_nan())     # NaN判定

# null値の補完
pldf.with_columns(pl.col("値").fill_null(0))

# NaN値の補完
pldf.with_columns(pl.col("値").fill_nan(0))

# null値を持つ行を削除
pldf.drop_nulls(subset=["値"])

Polarsではnull(データの欠損)とNaN(数値演算の結果としての非数)が明確に区別されるのが重要です。pandasではこの2つが混同されがちで、「なんでfillna()で埋まらないの?」みたいなバグの温床になっていました。Polarsならそういう問題は起きません。

apply vs map_elements

# ===== pandas =====
# カスタム関数の適用
pdf["年収ランク"] = pdf["年収"].apply(lambda x: "高" if x >= 500 else "低")


# ===== Polars =====
# map_elementsは使えるが、パフォーマンスが落ちるので非推奨
pldf.with_columns(
    pl.col("年収").map_elements(
        lambda x: "高" if x >= 500 else "低",
        return_dtype=pl.Utf8,
    ).alias("年収ランク")
)

# 推奨:Expression APIで書く(はるかに高速)
pldf.with_columns(
    pl.when(pl.col("年収") >= 500)
      .then(pl.lit("高"))
      .otherwise(pl.lit("低"))
      .alias("年収ランク")
)

Polarsでmap_elements(pandasのapplyに相当)を使うと、Python GILの制約でシングルスレッドになってしまい、Polarsの並列処理の恩恵を受けられません。できる限りExpression APIで書く。これがPolarsのパフォーマンスを最大化する最大のコツです。

主な違いのまとめ

  • インデックス:pandasにはある、Polarsにはない
  • 欠損値:pandasはNaN/None混在、Polarsはnull/NaNを厳密に区別
  • 型システム:pandasはobject型が許容される、Polarsは厳格な型
  • ミュータビリティ:pandasはin-place操作可能、Polarsはイミュータブル
  • 文字列型:pandasはobjectまたはstring、PolarsはUtf8(現在はString
  • 並列処理:pandasはシングルスレッド、Polarsはマルチスレッド

遅延評価(Lazy Evaluation)の威力

正直に言うと、Polarsを使う最大のメリットはこの遅延評価にあると思っています。EagerモードでもPolarsはpandasより速いですが、Lazyモードを使いこなせるようになると、パフォーマンスが文字通り桁違いになります。

LazyFrameの概念

LazyFrameは、実際のデータではなく「何をするかの計画(クエリプラン)」を保持するオブジェクトです。SQLのクエリプランナーに近い概念ですね。

import polars as pl

# DataFrameからLazyFrameに変換
lf = pl.DataFrame({
    "名前": ["田中", "佐藤", "鈴木"],
    "年齢": [28, 35, 42],
    "年収": [450, 620, 580],
}).lazy()

# この時点ではまだ何も実行されていない
query = (
    lf
    .filter(pl.col("年齢") >= 30)
    .select("名前", "年収")
    .sort("年収", descending=True)
)

# クエリプランを確認
print(query.explain())

# .collect()で初めて実行される
result = query.collect()
print(result)

クエリ最適化

LazyFrameのすごいところは、.collect()が呼ばれる前に自動的にクエリ最適化が走ることです。主な最適化には以下のものがあります。

  • Predicate Pushdown(述語プッシュダウン):フィルタ条件をデータ読み込みの時点に移動し、不要なデータをそもそも読み込まない
  • Projection Pruning(射影枝刈り):最終結果に不要なカラムを読み込まない
  • Common Subexpression Elimination:同じ計算を繰り返さない
  • Type Coercion Optimization:型変換を最適化する
import polars as pl

# 大きなCSVファイルからLazyFrameを作成
lf = pl.scan_csv("large_dataset.csv")

# 複雑なクエリを構築
query = (
    lf
    # 100カラムあるうちの3つだけ使う → Projection Pruning
    .select("user_id", "purchase_amount", "created_at")
    # フィルタ条件 → Predicate Pushdown
    .filter(pl.col("purchase_amount") > 1000)
    # 集計
    .group_by("user_id")
    .agg(
        pl.col("purchase_amount").sum().alias("合計購入額"),
        pl.col("purchase_amount").count().alias("購入回数"),
    )
    .sort("合計購入額", descending=True)
    .head(10)
)

# 最適化されたクエリプランを確認
print(query.explain())
# → CSVファイルから3カラムだけ読み込み、
#   purchase_amount > 1000の行だけスキャンする最適化プランが表示される

result = query.collect()

この最適化はParquetファイルとの相性が抜群です。Parquetはカラムナフォーマットなので、Projection Pruningにより不要なカラムのデータをディスクからそもそも読み込まなくて済みます。

scan_csv vs read_csv

ここで改めて違いを整理しておきましょう。

import polars as pl

# read_csv: ファイル全体をメモリに読み込む(Eager)
df = pl.read_csv("data.csv")        # → DataFrame

# scan_csv: メタデータだけ読み、実際の読み込みは最適化後(Lazy)
lf = pl.scan_csv("data.csv")        # → LazyFrame

# scan_parquet: Parquetの場合(列指向なのでさらに最適化が効く)
lf = pl.scan_parquet("data.parquet") # → LazyFrame

# S3などのクラウドストレージからも直接スキャン可能
lf = pl.scan_parquet("s3://my-bucket/data/*.parquet")

大規模データを扱う場合は、常にscan_*を使うのを習慣にするのがおすすめです。

ストリーミングエンジンとGPUアクセラレーション

新ストリーミングエンジン(v1.31以降)

Polars v1.31で導入された新しいストリーミングエンジンは、メモリに収まりきらない大規模データセットを処理するためのゲームチェンジャーです。Morsel-Driven Parallelismという研究に基づいた設計で、データを小さなチャンク(morsel)に分割して並列処理します。

import polars as pl

# ストリーミングエンジンを使った処理
lf = pl.scan_parquet("huge_dataset/*.parquet")

result = (
    lf
    .filter(pl.col("status") == "active")
    .group_by("region")
    .agg(
        pl.col("revenue").sum().alias("総売上"),
        pl.col("user_id").n_unique().alias("ユニークユーザー数"),
    )
    .sort("総売上", descending=True)
    .collect(engine="streaming")  # ストリーミングエンジンを指定
)

print(result)

sink_*メソッド:メモリに収まらないデータの書き出し

結果をメモリに収集する代わりに、直接ファイルに書き出すsink_*メソッドも非常に強力です。

import polars as pl

# メモリに収まらないデータを変換してParquetに書き出す
lf = pl.scan_csv("huge_file.csv")

(
    lf
    .filter(pl.col("year") >= 2024)
    .with_columns(
        (pl.col("price") * pl.col("quantity")).alias("total"),
    )
    .sink_parquet("output/filtered_data.parquet")
    # → データがストリーミングで処理され、直接ファイルに書き込まれる
    # → メモリには全データが載らなくてもOK
)

# CSVへの書き出しも同様
lf.sink_csv("output/filtered_data.csv")

sink_parquetを使えば、16GBのメモリしかないノートPCでも数百GBのデータセットを処理できる可能性があります。pandasでは考えられなかったことですよね。

collect_batchesによるチャンク処理

最近追加されたcollect_batches()メソッドも便利です。結果をジェネレーターとして少しずつ受け取れます。

import polars as pl

lf = pl.scan_parquet("large_dataset.parquet")

query = (
    lf
    .filter(pl.col("category") == "electronics")
    .select("product_name", "price", "rating")
)

# バッチごとに処理(メモリ効率が良い)
for batch_df in query.collect_batches():
    # 各バッチはDataFrame
    print(f"バッチサイズ: {batch_df.shape[0]}行")
    # バッチごとに何らかの処理を行う
    process_batch(batch_df)

GPUアクセラレーション(RAPIDS cuDF連携)

NVIDIA GPUを搭載している環境では、RAPIDS cuDFと連携してGPU上でクエリを実行できます。2026年2月時点ではオープンベータとして提供されています。

# GPU対応版のインストール
# pip install 'polars[gpu]'

import polars as pl

lf = pl.scan_parquet("data.parquet")

# GPUエンジンで実行
result = (
    lf
    .filter(pl.col("amount") > 100)
    .group_by("category")
    .agg(pl.col("amount").sum())
    .collect(engine="gpu")  # GPUで実行!
)

# GPUエンジンの設定をカスタマイズする場合
from polars import GPUEngine

gpu_engine = GPUEngine(
    device=0,        # GPU番号
    memory_limit=8_000_000_000,  # VRAMの制限(8GB)
)

result = lf.collect(engine=gpu_engine)

GPUエンジンを使うと、特にgroup byや集計処理で最大13倍の高速化が報告されています。ただし、すべてのPolars操作がGPUでサポートされているわけではなく、非対応の操作はCPUにフォールバックされる点は覚えておいてください。

各モードの使い分け

  • Eager(通常):小規模データ、探索的分析、インタラクティブな作業
  • Lazy + collect():中規模データ、本番パイプライン、クエリ最適化が必要な場合
  • Lazy + collect(engine="streaming"):メモリに収まらない大規模データ
  • Lazy + sink_*():大規模データの変換・書き出し、ETLバッチ処理
  • Lazy + collect(engine="gpu"):GPU搭載環境での大規模集計処理

パフォーマンス比較:Polars vs pandas

さて、皆さんが一番気になるのはやっぱりパフォーマンスですよね。実際のベンチマーク結果を見てみましょう。

操作別ベンチマーク

以下は、2025年に公開された複数のベンチマークを総合した結果です(1GBのCSVデータセットを使用)。

# ベンチマーク結果(概算値、環境により変動)
#
# 操作                 | pandas    | Polars    | 倍率
# --------------------|-----------|-----------|------
# CSV読み込み          | 12.5秒    | 0.5秒     | 25x
# Parquet読み込み      | 2.1秒     | 0.3秒     | 7x
# フィルタリング       | 1.8秒     | 0.4秒     | 4.5x
# ソート              | 5.2秒     | 0.4秒     | 13x
# Group By + 集計     | 3.5秒     | 0.5秒     | 7x
# Join(100万行同士) | 4.1秒     | 0.8秒     | 5x
# 文字列処理          | 8.3秒     | 1.2秒     | 7x

メモリ使用量の比較

速度だけじゃなく、メモリ使用量にも大きな差があります。

# メモリ使用量の比較(1GBのCSVデータ読み込み後)
#
# pandas:  約1.4GB(元のCSVサイズより大きくなることも)
# Polars:  約0.18GB(Apache Arrowの効率的なメモリレイアウト)
#
# → Polarsはpandasの約1/8のメモリで済む

これはかなり衝撃的な結果です。Apache Arrowのカラムナフォーマットによる効率的なメモリレイアウトと、文字列の辞書エンコーディングなどの最適化が効いています。

Polarsが得意なケース

  • 大規模データ(数百万〜数億行)の処理
  • 複雑なgroup by/集計処理
  • 大量のCSV/Parquetファイルの読み書き
  • ETLパイプラインのバッチ処理
  • メモリ制約が厳しい環境

pandasが依然として優位なケース

  • 小規模データ(数千〜数万行)の探索的分析
  • Jupyter Notebookでのインタラクティブな作業
  • scikit-learn、matplotlib、seabornなどとの連携
  • 既存のpandasエコシステムに依存するライブラリの利用
  • チーム全員がpandasに慣れている場合

ただし、エコシステムの状況は急速に変化しています。StreamlitがPolarsのネイティブサポートを追加し、DuckDBがPolarsのDataFrameを直接クエリできるようになるなど、Polarsの活躍の場は日に日に広がっています。scikit-learnも内部でPolarsのサポートを始めていますし、この流れは今後も加速するでしょう。

簡単なベンチマークを自分で試す

百聞は一見にしかず。自分の環境で試してみるのが一番です。

import time
import pandas as pd
import polars as pl
import numpy as np

# テストデータの生成(500万行)
n_rows = 5_000_000
np.random.seed(42)

data = {
    "id": np.arange(n_rows),
    "group": np.random.choice(["A", "B", "C", "D", "E"], n_rows),
    "value1": np.random.randn(n_rows),
    "value2": np.random.uniform(0, 1000, n_rows),
}

# pandas DataFrame
pdf = pd.DataFrame(data)
# Polars DataFrame
pldf = pl.DataFrame(data)

# ----- Group By + 集計のベンチマーク -----

# pandas
start = time.perf_counter()
result_pd = pdf.groupby("group").agg({
    "value1": ["mean", "std"],
    "value2": ["sum", "max"],
})
elapsed_pd = time.perf_counter() - start
print(f"pandas: {elapsed_pd:.3f}秒")

# Polars
start = time.perf_counter()
result_pl = pldf.group_by("group").agg(
    pl.col("value1").mean().alias("value1_mean"),
    pl.col("value1").std().alias("value1_std"),
    pl.col("value2").sum().alias("value2_sum"),
    pl.col("value2").max().alias("value2_max"),
)
elapsed_pl = time.perf_counter() - start
print(f"Polars: {elapsed_pl:.3f}秒")

print(f"速度比: {elapsed_pd / elapsed_pl:.1f}x")

実践的なETLパイプラインの構築

ここまでの知識を総動員して、実践的なETLパイプラインを組んでみましょう。ECサイトの売上データを分析するシナリオを想定します。

データの定義と読み込み

import polars as pl
from datetime import datetime, date

# サンプルデータの作成(実際にはscan_parquetなどで読み込む)
orders = pl.DataFrame({
    "order_id": range(1, 11),
    "user_id": [101, 102, 101, 103, 102, 104, 101, 103, 105, 102],
    "product_id": [1, 2, 3, 1, 4, 2, 5, 3, 1, 5],
    "quantity": [2, 1, 3, 1, 2, 1, 1, 4, 2, 3],
    "unit_price": [1500, 3200, 800, 1500, 2100, 3200, 4500, 800, 1500, 4500],
    "order_date": [
        date(2025, 1, 15), date(2025, 1, 16), date(2025, 1, 17),
        date(2025, 2, 1), date(2025, 2, 5), date(2025, 2, 10),
        date(2025, 3, 1), date(2025, 3, 5), date(2025, 3, 10),
        date(2025, 3, 15),
    ],
    "status": ["completed", "completed", "cancelled", "completed",
               "completed", "returned", "completed", "completed",
               "completed", "completed"],
})

products = pl.DataFrame({
    "product_id": [1, 2, 3, 4, 5],
    "product_name": ["ノートPC", "ヘッドフォン", "マウスパッド", "キーボード", "モニター"],
    "category": ["PC", "オーディオ", "アクセサリ", "アクセサリ", "PC"],
})

users = pl.DataFrame({
    "user_id": [101, 102, 103, 104, 105],
    "user_name": ["田中太郎", "佐藤花子", "鈴木一郎", "高橋美咲", "伊藤健太"],
    "registration_date": [
        date(2024, 1, 1), date(2024, 3, 15), date(2024, 6, 1),
        date(2024, 9, 1), date(2025, 1, 1),
    ],
})

ETLパイプライン全体

import polars as pl

def build_sales_report(
    orders: pl.DataFrame,
    products: pl.DataFrame,
    users: pl.DataFrame,
) -> pl.DataFrame:
    """売上分析レポートを生成するETLパイプライン"""

    report = (
        orders
        .lazy()
        # ===== Extract & Filter =====
        # 完了済みの注文のみを対象にする
        .filter(pl.col("status") == "completed")

        # ===== Transform =====
        # 売上金額を計算
        .with_columns(
            (pl.col("quantity") * pl.col("unit_price")).alias("total_amount"),
            # 注文月を抽出
            pl.col("order_date").dt.month().alias("order_month"),
        )

        # 商品情報をJoin
        .join(products.lazy(), on="product_id", how="left")

        # ユーザー情報をJoin
        .join(users.lazy(), on="user_id", how="left")

        # ユーザーの登録からの日数を計算
        .with_columns(
            (pl.col("order_date") - pl.col("registration_date"))
                .dt.total_days()
                .alias("days_since_registration"),
        )

        # ユーザーセグメントを付与
        .with_columns(
            pl.when(pl.col("days_since_registration") <= 90)
              .then(pl.lit("新規"))
              .when(pl.col("days_since_registration") <= 365)
              .then(pl.lit("一般"))
              .otherwise(pl.lit("ロイヤル"))
              .alias("user_segment"),
        )

        # 最適化されたクエリを実行
        .collect()
    )

    return report


def generate_summary(report: pl.DataFrame) -> dict:
    """レポートからサマリー統計を生成"""

    # カテゴリ別の売上サマリー
    category_summary = (
        report
        .group_by("category")
        .agg(
            pl.col("total_amount").sum().alias("売上合計"),
            pl.col("total_amount").mean().round(0).alias("平均注文額"),
            pl.col("order_id").n_unique().alias("注文数"),
            pl.col("user_id").n_unique().alias("ユニークユーザー数"),
        )
        .sort("売上合計", descending=True)
    )

    # 月次売上サマリー
    monthly_summary = (
        report
        .group_by("order_month")
        .agg(
            pl.col("total_amount").sum().alias("月次売上"),
            pl.col("order_id").n_unique().alias("月次注文数"),
        )
        .sort("order_month")
    )

    # ユーザーセグメント別サマリー
    segment_summary = (
        report
        .group_by("user_segment")
        .agg(
            pl.col("total_amount").sum().alias("セグメント売上"),
            pl.col("user_id").n_unique().alias("ユーザー数"),
            pl.col("total_amount").mean().round(0).alias("平均購入額"),
        )
    )

    return {
        "category": category_summary,
        "monthly": monthly_summary,
        "segment": segment_summary,
    }


# パイプラインの実行
report = build_sales_report(orders, products, users)
print("=== 売上レポート ===")
print(report)

summaries = generate_summary(report)
print("
=== カテゴリ別サマリー ===")
print(summaries["category"])
print("
=== 月次サマリー ===")
print(summaries["monthly"])
print("
=== セグメント別サマリー ===")
print(summaries["segment"])

このETLパイプライン、.lazy()から始まって.collect()で終わるという構造がポイントです。途中のフィルタ、Join、変換がすべて最適化された上で一括実行されます。

大規模データ向けのストリーミングパイプライン

実際の本番環境だと、データが大きすぎてメモリに載らないことはよくあります。そんなときのパイプラインがこちらです。

import polars as pl

def streaming_etl_pipeline(
    input_path: str,
    output_path: str,
) -> None:
    """
    メモリに収まらない大規模データを処理するストリーミングETLパイプライン
    """
    (
        pl.scan_parquet(input_path)
        # フィルタリング(Predicate Pushdownで最適化される)
        .filter(
            pl.col("status") == "completed",
            pl.col("order_date") >= pl.lit("2025-01-01"),
        )
        # 必要なカラムだけ選択(Projection Pruningで最適化される)
        .select(
            "order_id", "user_id", "product_id",
            "quantity", "unit_price", "order_date",
        )
        # 変換
        .with_columns(
            (pl.col("quantity") * pl.col("unit_price")).alias("total_amount"),
            pl.col("order_date").str.to_date().dt.month().alias("month"),
        )
        # 結果を直接Parquetファイルに書き出し
        .sink_parquet(output_path)
    )
    print(f"ETL完了: {output_path}")


# 実行
streaming_etl_pipeline(
    input_path="s3://data-lake/orders/**/*.parquet",
    output_path="output/processed_orders.parquet",
)

エラーハンドリングとデータバリデーション

本番で使うなら、エラーハンドリングもちゃんとやっておきたいですよね。

import polars as pl

def validate_and_clean(lf: pl.LazyFrame) -> pl.LazyFrame:
    """データのバリデーションとクリーニング"""

    return (
        lf
        # null値を含む重要カラムの行を削除
        .drop_nulls(subset=["user_id", "product_id"])

        # 異常値の除去(価格が0以下や極端に高いもの)
        .filter(
            pl.col("unit_price").is_between(1, 1_000_000),
            pl.col("quantity").is_between(1, 10_000),
        )

        # 日付の範囲チェック
        .filter(
            pl.col("order_date") >= pl.lit("2020-01-01"),
            pl.col("order_date") <= pl.lit("2026-12-31"),
        )

        # 文字列カラムのクリーニング
        .with_columns(
            pl.col("status").str.to_lowercase().str.strip_chars(),
        )

        # 重複行の除去
        .unique(subset=["order_id"], keep="first")
    )


def safe_etl(input_path: str, output_path: str) -> None:
    """エラーハンドリング付きETLパイプライン"""
    try:
        lf = pl.scan_parquet(input_path)

        # スキーマの確認
        schema = lf.collect_schema()
        required_columns = {"order_id", "user_id", "product_id",
                           "quantity", "unit_price", "order_date", "status"}

        missing = required_columns - set(schema.names())
        if missing:
            raise ValueError(f"必須カラムが不足しています: {missing}")

        # バリデーション・クリーニング・書き出し
        validated = validate_and_clean(lf)
        validated.sink_parquet(output_path)

        # 結果の確認
        result = pl.scan_parquet(output_path)
        row_count = result.select(pl.len()).collect().item()
        print(f"処理完了: {row_count}行を書き出しました → {output_path}")

    except pl.exceptions.SchemaError as e:
        print(f"スキーマエラー: {e}")
    except pl.exceptions.ComputeError as e:
        print(f"計算エラー: {e}")
    except Exception as e:
        print(f"予期しないエラー: {e}")
        raise

まとめと今後の展望

Polars vs pandas:いつどちらを選ぶべきか

ここまで読んでくださった方なら、Polarsの強力さが伝わったんじゃないかと思います。じゃあ実際のプロジェクトではどちらを選ぶべきか?私なりの判断基準をまとめてみます。

Polarsを選ぶべきケース:

  • データサイズが100万行を超える場合
  • ETL/データパイプラインの本番コードを書く場合
  • メモリ効率が重要な場合
  • 処理速度が要件として求められる場合
  • 新規プロジェクトでゼロから技術選定できる場合
  • Parquetファイルを中心にデータ管理している場合

pandasを選ぶべきケース:

  • 小規模データの探索的データ分析(EDA)
  • 既存のpandasコードベースが大量にある場合
  • pandas専用のライブラリ(一部のML系ツールなど)を使う必要がある場合
  • チームの学習コストを最小限にしたい場合

そしてもう一つの選択肢として、両方使うのも全然アリです。Polarsは.to_pandas()でpandasのDataFrameに簡単に変換できますし、逆にpl.from_pandas()でPolarsに変換するのも一瞬です。

import polars as pl
import pandas as pd

# Polarsで高速に処理して...
pldf = (
    pl.scan_parquet("large_data.parquet")
    .filter(pl.col("value") > 100)
    .group_by("category")
    .agg(pl.col("value").mean())
    .collect()
)

# scikit-learnに渡すときだけpandasに変換
pdf = pldf.to_pandas()
# model.fit(pdf[features], pdf[target])

# 逆変換も簡単
pldf_back = pl.from_pandas(pdf)

Polars Cloudとエコシステムの拡大

2025年9月にはPolars CloudがAWS上で一般提供開始されました。分散処理エンジンもオープンベータとして提供されています。ローカルマシンでは処理しきれないデータも、コードをほとんど変えずにクラウド上でスケールアウトできるようになったわけです。

# Polars Cloudの利用イメージ(概念コード)
import polars as pl
import polars.cloud as pc

# ローカルで開発したクエリをそのままクラウドで実行
lf = pl.scan_parquet("s3://my-bucket/data/**/*.parquet")

query = (
    lf
    .filter(pl.col("date") >= "2025-01-01")
    .group_by("region", "product")
    .agg(pl.col("revenue").sum())
)

# ローカルで実行
local_result = query.collect()

# Polars Cloud上で分散実行(コードの変更は最小限)
cloud_result = query.collect(engine=pc.distributed())

エコシステム面でも着実に進展しています。

  • Streamlit:Polars DataFrameのネイティブサポートを追加
  • DuckDB:PolarsのDataFrameを直接SQLでクエリ可能
  • scikit-learn:Polars DataFrameの入力をサポート
  • Delta Lake / Iceberg:Polarsからの読み書きに対応
  • Great Expectations / Pandera:Polars対応を追加中

今後の展望

Polarsは今後もさらに進化を続けるでしょう。特に注目したいのは以下の点です。

  • ストリーミングエンジンの完成度向上:新しいストリーミングエンジンがデフォルトになり、メモリに収まらないデータの処理がさらにシームレスに
  • GPU対応の拡充:より多くの操作がGPUで実行可能になり、パフォーマンスがさらに向上
  • Polars Cloudの成熟:分散処理がより安定し、エンタープライズでの採用が加速
  • エコシステムの拡大:より多くのライブラリがPolarsをネイティブサポート

最後に

この記事では、Polarsの基本概念からインストール、Expression API、pandasからの移行方法、遅延評価、ストリーミングエンジン、GPU対応、パフォーマンス比較、そして実践的なETLパイプラインまで幅広くカバーしました。

個人的な推奨としては、pandasとPolarsの両方を学んで、適材適所で使い分けるのがベストだと考えています。pandasの知識は無駄にはなりません。むしろ、Polarsの設計思想を理解することで、pandasのコードもより良く書けるようになるはずです。

まだPolarsを触ったことがない方は、まずpip install polarsして、普段pandasでやっている処理をPolarsで書き直してみてください。そのスピードにはきっと驚くと思います。そして一度Lazyモードの威力を体験したら……もうEagerモードには戻れないかもしれませんよ。

当サイトのPandas 3.0の新機能の記事と合わせて読めば、2026年のPythonデータ処理における最新のツールセットを一通り把握できます。pandasもPolarsもそれぞれの進化を続けていますが、大事なのは「データから価値あるインサイトを引き出す」こと。ツールはそのための手段にすぎません。自分のユースケースに最適なものを選んでいきましょう。

著者について Editorial Team

Our team of expert writers and editors.