Polars 是以 Rust 撰寫、採用 Apache Arrow 記憶體格式的 DataFrame 引擎,靠多執行緒與查詢優化器加速。在 2 vCPU、500 萬列的實測中,它的分組聚合比 pandas 快 4.7 倍、排序快 6.8 倍、記憶體少 32%;但讀 CSV 只快 1.2 倍——這正是 2026 年該重新理解 Polars 的地方。
Polars 是什麼?先看基本資料
| 項目 | 內容 |
|---|---|
| 定位 | 高效能 DataFrame 查詢引擎(Rust 核心) |
| 起源 | 2020 年由 Ritchie Vink 發起 |
| 授權 | MIT(商用友善) |
| GitHub | pola-rs/polars,40,309 ★、3,236 fork(2026-11-10 查詢) |
| 最新版本 | py-2.1.0(2026-10-25 發布) |
| 官方文件 | docs.pola.rs |
| 語言綁定 | Python、Rust、Node.js、R |
| 特殊能力 | LazyFrame 查詢優化、spill-to-disk、SQL 介面、GPU 加速 |
安裝就一行:
pip install polars
寫法上,一個最小的 lazy 查詢長這樣(這是官方 README 的範例,直接跑得動):
import polars as pl
df = (
pl.scan_parquet("orders.parquet")
.filter(pl.col("status") == "shipped")
.group_by("customer_id")
.agg(
pl.col("amount").sum().alias("total"),
pl.len().alias("n_orders"),
)
.sort("total", descending=True)
.collect()
)
注意三件事:scan_parquet 是延遲讀取(不會立刻載入)、pl.col() 是表達式(不是字串欄名)、最後的 .collect() 才真正執行。這三個習慣換過來,效能差距才吃得滿。
為什麼快?三個機制
- Arrow 欄式記憶體:同一欄的資料在記憶體中連續排列,CPU 快取命中率高,也能走 SIMD 向量化指令;跨工具傳遞可零拷貝。
- 真多執行緒:pandas 的核心運算本質是單執行緒(要平行得靠 Dask),Polars 預設吃滿所有核心,分組用 lock-free 雜湊避免鎖競爭。
- 查詢優化器:LazyFrame 會先建查詢計畫再最佳化。實際跑
.explain()看得到它做了什麼:
AGGREGATE[maintain_order: false]
[col("price").sum()] BY [col("city")]
FROM
Parquet SCAN [/tmp/pl_bench.parquet]
PROJECT 2/7 COLUMNS ← 只讀 2 欄,不是 7 欄
SELECTION: col("price") > 1000.0 ← 篩選下推到掃描層
ESTIMATED ROWS: 5000000
PROJECT 2/7 COLUMNS 是 projection pushdown、SELECTION 那行是 predicate pushdown。意思是:檔案有 7 欄,但只讀你要的 2 欄;5,000 萬列裡不符合條件的列,根本不會進記憶體。這是 pandas 做不到的層級——pandas 一定得先全部載入再篩。
實測:500 萬列、2 vCPU 的對照
測試環境:2 vCPU、7 GB RAM 的 VPS(pl.thread_pool_size() 回報 2 條執行緒)、Python 3.12、pandas 3.1.1、polars 2.1.0、pyarrow 26.0.1。資料為 500 萬列 × 7 欄的模擬訂單表,CSV 226.4 MB、轉 Parquet 後 63.4 MB。每項取三次最佳值,避免冷快取誤差。
| 任務 | pandas 3.1.1 | Polars 2.1.0 | 倍率 |
|---|---|---|---|
| 讀 CSV(226 MB) | 0.548 s | 0.452 s | Polars 快 1.2 倍 |
| 讀 Parquet(63 MB) | 0.371 s | 0.071 s | Polars 快 5.2 倍 |
| filter + groupby(記憶體內) | 0.828 s | 0.178 s | Polars 快 4.7 倍 |
| 字串 contains 篩選 | 0.453 s | 0.112 s | Polars 快 4.0 倍 |
| join(500 萬 × 8 列維度表) | 0.979 s | 0.271 s | Polars 快 3.6 倍 |
| 排序後取前 100 | 7.673 s | 1.131 s | Polars 快 6.8 倍 |
| Parquet 讀取+groupby 全流程 | 0.534 s | 0.099 s | Polars 快 5.4 倍 |
| 資料在記憶體佔用 | 362.4 MB | 248.0 MB | Polars 少 32% |
兩個必須說清楚的觀察:
- 讀 CSV 幾乎追平了。 2024 年那種「Polars 讀 CSV 快 3–5 倍」的說法,在 pandas 3 之後不成立——因為 pandas 3 預設用 Arrow 支撐的字串型別(
pd.Series(['a','b']).dtype現在回str,不是object),C 解析器本身也很快。真正拉開差距的是壓縮格式(Parquet 5.2 倍)與計算(4–7 倍)。 - 排序的 6.8 倍是單執行緒 vs 多執行緒的直觀結果。 pandas 要 7.67 秒,Polars 1.13 秒;pandas 這邊還得先付出記憶體複製成本。
第三方獨立測試的數字方向一致。Uvik Software 在 2026 年 10 月發布的評測(2 vCPU、8 GB RAM、1,000 萬列)測到:groupby 快 5.7 倍(2.86 s vs 16.39 s)、join 快 3.75 倍、讀 CSV 快 1.28 倍、讀 Parquet 快 2.43 倍;把資料放大到 5,000 萬列並限制記憶體 5.84 GiB 時,pandas 直接 OOM 崩潰,Polars 完成 9/10 題。
有趣的是倍率會隨機器放大:Polars 官方在 96 vCPU 機器上跑 PDS-H 測到比 pandas 快 94 倍,而 8 核筆電的社群實測只有約 3.3 倍。核心數越少,差距越接近本文這組數字——這也是為什麼一台 2 vCPU 的小 VPS 上,Polars 是「有感」而不是「神效」。
一個反直覺的實測:Lazy 不是永遠更快
同一份資料,如果已經在記憶體裡,那 lazy 反而慢:
| 情境 | 時間 |
|---|---|
pl.read_csv() 後直接 eager groupby | 0.178 s |
pl.scan_csv() → filter → groupby → collect | 0.837 s |
pl.scan_parquet() + groupby(lazy) | 0.099 s |
原因很簡單:scan_csv 每呼叫一次就要重新解析 226 MB 的 CSV,而 eager 版本只付一次解析成本。lazy 的價值在「一次掃描、多層轉換」與壓縮格式:Parquet 加 lazy 只要 0.099 秒,比 pandas 全流程 0.534 秒快 5.4 倍。
實務判斷原則:
- 手邊的檔案是 CSV/JSON 且只查一次 → eager
read_csv就好。 - 檔案是 Parquet/多檔 glob/需要連續 3 個以上轉換 → 用
scan_*+ lazy。 - 資料量大於記憶體 → lazy + spill-to-disk(Polars 2.x 預設啟用,超過 RAM 約 80% 就自動溢出到磁碟)。
2026 年的 pandas 也變了,這會影響你的遷移決策
很多「Polars 教學」還停在 pandas 1.x/2.x 的比較,但 pandas 3 已經改了幾個關鍵行為(以下都在 pandas 3.1.1 實測):
| 項目 | pandas 3 行為 | 對遷移的意義 |
|---|---|---|
| Copy-on-Write | 全面預設;寫入切片不會改到原 DataFrame(實測原 df['b'] 仍是 [4,5,6]) | 記憶體複製變少,pandas 不再是「到處 copy」的代名詞 |
| 鏈式賦值 | df['b'][mask] = 7 完全失效,數值不會寫回(實測 b 仍是 [0,0,0]),並拋 ChainedAssignmentError | 舊程式碼升級 pandas 3 就會壞,這與 Polars 無關 |
| 字串型別 | 預設 str(Arrow 後端),不再是 object | CSV/字串處理的差距被吃掉一大半 |
to_numpy() | 預設唯讀,寫入會 ValueError(實測) | 共用記憶體的舊技巧會踩雷 |
| 表達式 | 新增 pd.col()(實測 hasattr(pd,'col') 為 True) | 語法正在往 Polars 靠攏 |
| Python 版本 | 要求 3.11 以上 | 舊環境得先升級 |
結論:如果你只是嫌 pandas 慢,先升到 pandas 3 並裝 pyarrow,可能就解決一半問題;如果你要的是多執行緒計算、遠大於記憶體的資料、或確定性的型別,那才是 Polars 的主場。
pandas → Polars:12 組寫法對照
| 想做什麼 | pandas | Polars |
|---|---|---|
| 選欄位 | df[['a','b']] | df.select(['a','b']) |
| 條件篩選 | df[df.a > 10] | df.filter(pl.col('a') > 10) |
| 新增欄位 | df.assign(x=df.a*10) | df.with_columns((pl.col('a')*10).alias('x')) |
| 條件賦值 | df['a'].mask(cond, df['b']) | pl.when(cond).then(pl.col('b')).otherwise(pl.col('a')).alias('a') |
| 分組聚合 | df.groupby('c').agg({'v':'sum'}) | df.group_by('c').agg(pl.col('v').sum()) |
| 分組筆數 | df.groupby('c').size() | df.group_by('c').agg(pl.len()) |
| 排序 | df.sort_values('v') | df.sort('v') |
| 前 N 列 | df.head(100) | df.head(100) |
| 欄位改名 | df.rename(columns={'a':'b'}) | df.rename({'a':'b'}) |
| 串接 | pd.concat([df1,df2]) | pl.concat([df1,df2]) |
| 視窗函式 | df.groupby('c')['v'].transform('sum') | pl.col('v').sum().over('c') |
| 轉回 pandas | — | df.to_pandas() |
實際跑過的完整範例(含輸出):
import polars as pl
df = pl.DataFrame({
'city': ['Taipei','Taipei','Taichung','Tainan','Kaohsiung','Taichung'],
'month': [1,2,1,1,2,2],
'revenue': [100, 150, 80, 60, 90, 120],
'orders': [5, 7, 4, 3, 4, 6],
})
# 1) 新增欄位 + 篩選 + 分組 + 排序,一個 lazy 計畫做完
out = (df.lazy()
.with_columns((pl.col('revenue') / pl.col('orders')).alias('aov'))
.filter(pl.col('revenue') > 70)
.group_by('city')
.agg(pl.col('revenue').sum().alias('rev'),
pl.col('aov').mean().round(1).alias('aov'),
pl.len().alias('rows'))
.sort('rev', descending=True)
.collect())
輸出:
shape: (3, 4)
┌───────────┬─────┬──────┬──────┐
│ city ┆ rev ┆ aov ┆ rows │
│ str ┆ i64 ┆ f64 ┆ u32 │
╞═══════════╪═════╪══════╪══════╡
│ Taipei ┆ 250 ┆ 20.7 ┆ 2 │
│ Taichung ┆ 200 ┆ 20.0 ┆ 2 │
│ Kaohsiung ┆ 90 ┆ 22.5 ┆ 1 │
└───────────┴─────┴──────┴──────┘
視窗函式(算每個城市營收占比)也不必 transform 或 apply:
pct = (df.lazy()
.with_columns(pl.col('revenue').sum().over('city').alias('city_total'))
.with_columns((pl.col('revenue') / pl.col('city_total') * 100).round(1).alias('pct_of_city'))
.collect())
Taipei 兩筆得到 40.0 與 60.0,Tainan 只有一筆就是 100.0——結果與 pandas 的 transform('sum') 一致,但寫法短很多。
也可以直接寫 SQL
Polars 2.x 內建 SQL 介面,能與表達式混用(同一個優化器):
import polars as pl
orders = pl.scan_parquet("orders.parquet")
df = (
pl.sql("SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id")
.filter(pl.col("total") > 1_000)
.collect()
)
對已經熟悉 SQL 的團隊,這條路讓遷移成本大幅下降:先用 SQL 把邏輯搬過來,再逐步改寫成表達式。
四個「先不要換」的情況
Polars 不是萬靈丹,以下情況留在 pandas 更省事:
- 資料只有幾萬列。 差距在毫秒級,學習成本遠大於節省的時間。
- 下游是 scikit-learn、statsmodels、matplotlib 生態。 這些套件的一等公民仍是 pandas/NumPy,中間得靠
to_pandas()來回轉,反而多一層成本。 - 程式大量依賴 Index。 Polars 沒有 index(實測
hasattr(df,'set_index')為 False),時間序列的DatetimeIndex、頻率推斷、resample這些 pandas 慣用技都得改寫成顯式欄位操作。 - 需要
apply跑 Python 自訂函式。 Polars 的map_elements是逐列回退到 Python,慢又容易被官方標為「非慣用」;能用表達式改寫才有價值。
另外三個實務坑:
- 行序不保證。 Polars 2.x 的 streaming 引擎為了吞吐量,
join、group_by、unpivot預設不維護列順序,要順序就得明寫maintain_order=True。 - 錯誤訊息風格不同。 表達式型別錯誤會指向整個 expression 而不是某一欄,一開始會不習慣。
- 型別嚴格。 整數欄有缺值就是
null(欄位仍是整數),不會像 pandas 一樣偷偷轉成 float——這是優點,但舊報表邏輯要重驗。
選型建議:pandas / Polars / DuckDB 三者怎麼分
| 情境 | 建議 | 理由 |
|---|---|---|
| 探索性分析、資料量 < 100 萬列 | pandas | 生態最完整,AI 助理與範例最多 |
| 要接 sklearn/statsmodels 建模 | pandas | 中間轉換成本最低 |
| ETL、批次處理、> 1,000 萬列 | Polars | 多執行緒+lazy+spill-to-disk |
| 資料是 Parquet 資料湖、要跨多檔查詢 | Polars 或 DuckDB | 兩者都能只讀需要的欄位 |
| 團隊只會 SQL、不想學新 API | DuckDB | 直接用 SQL 查 Parquet/CSV,不必搬資料進記憶體 |
| 要跑在 GPU 上 | Polars | 2.x 支援 NVIDIA GPU 加速引擎 |
同一個站上已有一篇 DuckDB 完整教學,如果你的場景是「對一堆 Parquet 檔跑 SQL」,DuckDB 往往比 Polars 更省事;Polars 的優勢在需要複雜表達式與 Python 端流程控制時。兩者可混用:DuckDB 讀檔、Polars 做後續轉換。
30 分鐘上手路線
pip install polars,把一份現有 CSV 用pl.read_csv()讀進來,比對欄位型別。- 把
df[df.a > 10]改成df.filter(pl.col('a') > 10),體會表達式寫法。 - 找一段最慢的 groupby 腳本,用
scan_parquet+.explain()改寫,看PROJECT與SELECTION有沒有出現。 - 用
to_pandas()接回既有繪圖/建模流程,不必一次全搬。 - 量測:改寫前後各跑三次取最佳值,差距小於 20% 就別搬了。
站內延伸閱讀
- DuckDB 完整教學 2026:從安裝到查詢 Parquet 實戰
- uv 完整教學 2026:Rust 寫的極速 Python 套件管理器
- Scrapy 完整教學 2026:從安裝、Selector 到 Pipeline 上線
原始來源連結
- Polars 官方文件(Lazy API、Streaming、Pandas 遷移指南)
- Polars GitHub 儲存庫與 README
- Uvik Software:Polars vs pandas vs DuckDB 2026 Benchmark
- Apache Arrow 記憶體格式說明
(本文效能數字為在 2 vCPU/7 GB RAM 機器上的自測結果,資料為 500 萬列模擬訂單表;你的機器核心數與資料型態不同,倍率會不一樣,建議用同一份資料自行驗證。)
