一. 前言:資料交換不是把 DataFrame 丟來丟去而已 #
Python 做資料處理時,很容易以為世界只有兩種東西:小資料用 pandas,大一點就換 Polars、DuckDB,或直接丟資料庫。
這個想法不能說錯,但中間還有一層常常被忽略的東西:資料格式與記憶體表示。
你把 API 資料清理成表格,寫成 Parquet,給另一個 batch job 讀。你從 pandas 轉到 Polars,又把結果交給 DuckDB 查。你只想讀某幾欄,不想把整份檔案塞進記憶體。這些場景背後常常會遇到同一個名字:Apache Arrow。
在 Python 裡,最常用的 Arrow 工具就是 pyarrow。
pyarrow 不是新的 DataFrame 框架。它比較像資料工程裡的共通底座:
- 用 Arrow Array / Table 表示 columnar data。
- 用 schema 把欄位名稱與型別固定下來。
- 高效讀寫 Parquet。
- 讀取 partitioned dataset。
- 在 pandas、Polars、DuckDB、Spark、資料湖工具之間交換資料。
如果你看過 Python DuckDB 實戰,那篇重點是用 SQL 查本機 CSV / Parquet。如果你看過 Python Polars 實戰,那篇重點是用 DataFrame expression 做資料轉換。
今天這篇往下一層看:Parquet 和 Arrow 怎麼成為資料管線的邊界格式。
拍拍君先講結論:你不一定每天都要手寫 PyArrow,但你應該懂它。很多「為什麼欄位型別壞掉」、「為什麼只讀幾欄比較快」、「為什麼 parquet 比 csv 適合分析」的答案,都在這裡。
二. 安裝:把 Arrow 工具箱裝起來 #
建立一個練習專案:
mkdir pyarrow-lab
cd pyarrow-lab
uv init
uv add pyarrow pandas duckdb polars
不用 uv 的話:
python -m venv .venv
source .venv/bin/activate
pip install pyarrow pandas duckdb polars
確認版本:
import pyarrow as pa
print(pa.__version__)
pyarrow 裡有很多模組,今天最常用這幾個:
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
import pyarrow.dataset as ds
先記住分工:
pa:Array、Table、Schema 等核心型別。pc:向量化計算函式。pq:讀寫單一 Parquet 檔。ds:讀取一整個 dataset 目錄,包含 partition。
很多人第一次碰 PyArrow 會覺得 API 有點低階。這很正常。它不是要取代 pandas 或 Polars 的互動體驗,它的價值在於資料邊界:穩定、明確、可交換。
三. Arrow Array:一欄資料就是一個型別明確的陣列 #
Arrow 是 columnar 的,也就是說,它特別在意「一欄資料」要怎麼表示。
建立幾個 Array:
import pyarrow as pa
names = pa.array(["拍拍君", "拍拍醬", "chatPTT"])
scores = pa.array([98, 87, 91])
active = pa.array([True, True, False])
print(names.type)
print(scores.type)
print(active.type)
輸出大概是:
string
int64
bool
這跟 Python list 很不一樣。Python list 可以混放任何物件,Arrow Array 則像一條穩定的資料欄位:型別固定,適合批次處理、序列化、跨語言交換。
也可以明確指定型別:
amounts = pa.array([120, 300, 450], type=pa.int32())
prices = pa.array([12.5, 30.0, 45.2], type=pa.float64())
print(amounts.type)
print(prices.type)
當資料來源會漂移時,明確型別很重要。尤其是 CSV、JSON、外部 API 這些邊界。你不想今天某欄是 int,明天混進空字串後整欄變成字串。
拍拍君不是說型別一定能解決人生全部問題,但它至少會讓問題早一點露出來。
四. Table 與 Schema:不要把欄位型別交給猜測 #
多個 Array 可以組成 pa.Table:
import pyarrow as pa
import pyarrow.compute as pc
table = pa.table(
{
"order_id": pa.array([1, 2, 3], type=pa.int64()),
"customer": pa.array(["拍拍君", "拍拍醬", "chatPTT"]),
"city": pa.array(["Taipei", "Tainan", "Taipei"]),
"amount": pa.array([1200, 850, 430], type=pa.int64()),
}
)
print(table.column_names)
print(table.num_rows)
print(table.schema)
Table 的感覺有點像 DataFrame,但定位更接近資料交換格式。你可以選欄位:
small = table.select(["customer", "amount"])
print(small)
也可以新增欄位:
tax = pc.multiply(table["amount"], 0.05)
table_with_tax = table.append_column("tax", tax)
print(table_with_tax)
真實資料管線裡,拍拍君更建議把 schema 明確寫出來:
from datetime import datetime
schema = pa.schema(
[
("order_id", pa.int64()),
("customer", pa.string()),
("city", pa.string()),
("amount", pa.int64()),
("created_at", pa.timestamp("ms")),
]
)
table = pa.Table.from_arrays(
[
[1, 2, 3],
["拍拍君", "拍拍醬", "chatPTT"],
["Taipei", "Tainan", "Taipei"],
[1200, 850, 430],
[
datetime(2026, 6, 1, 10, 0),
datetime(2026, 6, 1, 11, 0),
datetime(2026, 6, 2, 9, 30),
],
],
schema=schema,
)
資料工程最常見的地雷之一,就是 schema drift。今天 order_id 是整數,明天某個來源送來 "N/A",後天有人把 amount 從元改成分,但欄位名沒改。如果你完全靠工具自動猜型別,debug 時就會開始考古。
壞資料最好早點失敗:
try:
pa.array(["1200", "oops"], type=pa.int64())
except pa.ArrowInvalid as exc:
print("資料型別不對:", exc)
在資料管線裡,這種錯誤越早出現越好。不要等到 dashboard 數字怪怪的才回頭找原始檔。
五. Parquet:不是比較潮的 CSV,是 columnar storage #
很多人第一次聽到 Parquet,會把它想成「比較快的 CSV」。這樣講雖然方便,但有點太委屈它。
CSV 是 row-oriented 文字格式。每一列是一筆資料,每個欄位靠逗號分隔。Parquet 是 columnar binary format。它把同一欄的資料放在一起,還保存 schema、metadata、壓縮資訊與 row group。
這對分析工作很重要,因為很多查詢只需要少數欄位。如果你只想算 amount,理論上不必把 customer、city、note 全部讀進來。
寫一個 Parquet:
import pyarrow.parquet as pq
pq.write_table(table, "orders.parquet")
讀回來:
loaded = pq.read_table("orders.parquet")
print(loaded.schema)
只讀部分欄位:
amounts = pq.read_table(
"orders.parquet",
columns=["order_id", "amount"],
)
print(amounts)
Parquet 在分析工作裡很香,就是因為欄位裁切可以少讀很多不需要的資料。如果你在 CSV 裡只想要兩欄,工具通常還是得掃過整列文字。
你也可以先讀 metadata,不把整份資料載進記憶體:
meta = pq.read_metadata("orders.parquet")
print(meta.num_rows)
print(meta.num_columns)
print(meta.num_row_groups)
print(meta.schema)
row group 是 Parquet 裡很重要的概念。你可以把它想成檔案內部的分塊,每個 row group 有自己的統計資訊。一些工具可以利用這些資訊跳過不需要讀的區塊。
寫檔時可以控制 row group size 與壓縮:
pq.write_table(
table,
"orders-zstd.parquet",
row_group_size=100_000,
compression="zstd",
)
真實專案裡你不一定會手動讀 row group,但懂這個概念後,你會比較知道為什麼 Parquet 檔不是越大越好、小檔太多也會拖慢 dataset 掃描、filter pushdown 需要檔案統計資訊配合。
六. Dataset:一個資料夾就是一份可掃描資料集 #
真實資料很少只有一個檔案,比較常見的是這樣:
data/orders/
year=2026/
month=06/
part-0.parquet
month=07/
part-0.parquet
這種叫 partitioned dataset。資料夾名稱本身也帶著欄位資訊。
先建立示範資料:
import pyarrow as pa
import pyarrow.dataset as ds
orders = pa.table(
{
"order_id": [1, 2, 3, 4],
"city": ["Taipei", "Tainan", "Taipei", "Kaohsiung"],
"amount": [1200, 850, 430, 990],
"year": [2026, 2026, 2026, 2026],
"month": [6, 6, 7, 7],
}
)
ds.write_dataset(
orders,
base_dir="data/orders",
format="parquet",
partitioning=["year", "month"],
existing_data_behavior="overwrite_or_ignore",
)
讀回 dataset:
dataset = ds.dataset(
"data/orders",
format="parquet",
partitioning="hive",
)
print(dataset.schema)
掃描六月資料,而且只讀需要欄位:
scanner = dataset.scanner(
columns=["order_id", "city", "amount"],
filter=ds.field("month") == 6,
)
june = scanner.to_table()
print(june)
這段很重要。columns 表示只讀需要的欄位,filter 表示只讀符合條件的資料。如果 partition 與 metadata 設計得好,工具就有機會少碰很多檔案。
這也是為什麼資料湖常常用 year=.../month=.../day=... 這種資料夾結構。不是因為大家喜歡把路徑弄得很長,是因為查詢時可以跳過不相關的 partition。
七. Compute:簡單轉換可以直接在 Arrow 裡做 #
PyArrow 有 compute 模組,提供很多向量化函式。
例如篩選:
table = pa.table(
{
"city": ["Taipei", "Tainan", "Taipei", "Kaohsiung"],
"amount": [1200, 850, 430, 990],
}
)
mask = pc.greater(table["amount"], 900)
filtered = table.filter(mask)
print(filtered)
新增計算欄位:
tax = pc.multiply(table["amount"], 0.05)
with_tax = table.append_column("tax", tax)
print(with_tax)
字串處理也可以:
upper_city = pc.utf8_upper(table["city"])
print(upper_city)
不過拍拍君要講實話:如果你要寫很多資料轉換邏輯,Polars 的 expression API 通常比較順。PyArrow compute 很適合做少量欄位處理、資料讀寫邊界的轉換、底層 adapter,或避免不必要地轉成 pandas。
不要把工具用成宗教。PyArrow 很強,但它不是每個資料分析任務最舒服的入口。
八. pandas / Polars / DuckDB:把 PyArrow 放在交換位置 #
PyArrow 最常見的用法之一,就是當交換格式。
從 pandas 轉 Arrow:
import pandas as pd
df = pd.DataFrame(
{
"order_id": [1, 2, 3],
"city": ["Taipei", "Tainan", "Taipei"],
"amount": [1200, 850, 430],
}
)
table = pa.Table.from_pandas(df, preserve_index=False)
print(table.schema)
轉回 pandas:
df2 = table.to_pandas()
print(df2)
Polars 也能接 Arrow:
import polars as pl
pl_df = pl.from_arrow(table)
print(pl_df)
back_to_arrow = pl_df.to_arrow()
print(back_to_arrow.schema)
DuckDB 可以直接查 Arrow Table:
import duckdb
result = duckdb.sql("""
SELECT city, SUM(amount) AS revenue
FROM table
GROUP BY city
""").df()
print(result)
這裡的 table 是 Python 作用域裡的 Arrow Table,DuckDB 能看到它。這就是 PyArrow 很適合當資料交換層的地方。
可以這樣分工:
| 任務 | 常用工具 |
|---|---|
| 快速 SQL 分析本機檔案 | DuckDB |
| 大量 DataFrame 轉換 | Polars |
| 互動式資料探索 | pandas / notebook |
| Parquet schema、metadata、dataset 邊界 | PyArrow |
拍拍君自己的習慣是:讓 PyArrow 負責資料邊界,不要硬把它變成所有邏輯的主舞台。工具各司其職,程式會比較不暴躁。
九. 實戰:把每日訂單寫成穩定 Parquet dataset #
假設你每天收到一批訂單資料,想寫成 Parquet dataset,讓後續工具可以用日期 partition 掃描。
建立 write_orders.py:
from __future__ import annotations
from datetime import date
from pathlib import Path
import pyarrow as pa
import pyarrow.dataset as ds
ORDER_SCHEMA = pa.schema(
[
("order_id", pa.int64()),
("customer", pa.string()),
("city", pa.string()),
("amount", pa.int64()),
("order_date", pa.date32()),
("year", pa.int16()),
("month", pa.int8()),
]
)
def build_order_table() -> pa.Table:
rows = {
"order_id": [1001, 1002, 1003, 1004],
"customer": ["拍拍君", "拍拍醬", "chatPTT", "小幫手"],
"city": ["Taipei", "Tainan", "Taipei", "Kaohsiung"],
"amount": [1200, 850, 430, 990],
"order_date": [
date(2026, 6, 1),
date(2026, 6, 1),
date(2026, 7, 2),
date(2026, 7, 2),
],
"year": [2026, 2026, 2026, 2026],
"month": [6, 6, 7, 7],
}
return pa.Table.from_pydict(rows, schema=ORDER_SCHEMA)
def main() -> None:
table = build_order_table()
Path("warehouse/orders").mkdir(parents=True, exist_ok=True)
ds.write_dataset(
table,
base_dir="warehouse/orders",
format="parquet",
partitioning=["year", "month"],
existing_data_behavior="overwrite_or_ignore",
)
if __name__ == "__main__":
main()
執行:
python write_orders.py
find warehouse/orders -type f
接著讀取六月資料:
import pyarrow.dataset as ds
dataset = ds.dataset(
"warehouse/orders",
format="parquet",
partitioning="hive",
)
scanner = dataset.scanner(
columns=["order_id", "customer", "city", "amount", "order_date"],
filter=(ds.field("year") == 2026) & (ds.field("month") == 6),
)
table = scanner.to_table()
print(table)
這個範例的重點不是四筆資料,而是流程:
- 寫入前先定義 schema。
- 用 Parquet 保存型別與 metadata。
- 用 partitioning 讓讀取可以跳過不相關資料。
- 讀取時只選需要的欄位。
- 後續要接 pandas、Polars、DuckDB 都可以。
小資料時你感覺不到差異。資料開始累積時,這些邊界設計就會救你。
十. 常見地雷:時間、空值與小檔案 #
PyArrow 很可靠,但你還是會踩坑。
先講時間。時間欄位請明確定義:
pa.field("created_at", pa.timestamp("ms", tz="UTC"))
如果你的系統跨時區,請不要把 naive datetime 到處傳。這題以前在 Python zoneinfo 實戰 講過,資料檔也一樣適用。
再來是空值。Arrow 支援 null,但你要知道欄位是否允許 null:
schema = pa.schema(
[
pa.field("order_id", pa.int64(), nullable=False),
pa.field("note", pa.string(), nullable=True),
]
)
第三個是小檔案。Parquet 很適合分析,但如果你把每十筆資料寫成一個檔案,檔案數爆炸後,掃描成本也會變高。
比較務實的做法是:
- 用 batch 累積到合理大小再寫。
- 用日期、類別等常用條件 partition。
- 不要 partition 到太細。
- 定期 compact 小檔案。
第四個是 schema 演進。真實資料管線欄位一定會變,新增欄位還算簡單,改欄位型別就要小心很多。拍拍君建議把 schema 寫成程式碼裡的常數,並搭配測試。
不要讓「某份檔案剛好能讀」變成你的資料契約。
結語:讓資料邊界清楚一點 #
今天我們從 Arrow Array、Table、Schema,一路看到 Parquet、metadata、dataset partition 與跨工具交換。
如果你只記一件事,請記這句:PyArrow 的重點不是讓你少寫兩行 pandas,而是讓資料邊界更明確。
資料欄位是什麼型別?Parquet 裡有多少 row group?讀取時能不能只拿幾欄?dataset 能不能用 partition 跳過不相關檔案?這些問題不酷炫,但很實用。
日常資料工作真正麻煩的地方,常常不是演算法。是資料格式今天一個樣、明天一個樣,工具 A 讀得懂、工具 B 讀壞掉,最後大家盯著一份檔案互相懷疑人生。
拍拍君的建議很簡單:在資料進出系統的地方,多花一點心力定義 schema、格式和 partition。未來的你會感謝現在的你。