快轉到主要內容
  1. 教學文章/

Python PyArrow 實戰:Parquet、Schema 與跨工具資料交換

·8 分鐘· loading · loading · ·
Python PyArrow Apache Arrow Parquet Data-Engineering ETL
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 93: 本文

featured

一. 前言:資料交換不是把 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,理論上不必把 customercitynote 全部讀進來。

寫一個 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。未來的你會感謝現在的你。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 93: 本文

相關文章

Python OpenTelemetry 實戰:Trace、Span 與 FastAPI 觀測流程
·6 分鐘· loading · loading
Python OpenTelemetry Observability FastAPI Tracing Developer-Tools
Textual Background Workers 實戰:長任務、Progress、取消與 Log Console
·9 分鐘· loading · loading
Python Textual TUI Background-Workers Async Developer-Tools
Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools