13. パーティション進化と hidden partitioning

samples/03_partition_evolution.py は、非パーティションのテーブルにパーティション仕様(spec)を 2 段階で追加し、その前後でデータを書きます。同一テーブル内に異なる spec で書かれたファイルが共存する様子と、クエリ側がパーティション列を意識しなくてよい仕組みを確認します。

Hive のパーティションはディレクトリ構造そのものだったため、後から変更するにはテーブルの作り直しが必要でした。Iceberg のパーティションはメタデータ上の定義で、変更は既存データに影響しません。

確かめたいこと

  • パーティション仕様を変更したとき、既存データが書き換えられるか
  • 古い spec と新しい spec が同一テーブル内に共存するか
  • パーティション列に触れないクエリで枝刈りが効くか
  • 古い spec で書かれたファイルのパーティション値がどう見えるか

背景

Iceberg のパーティションは、ソース列に変換(transform)を適用した結果として定義されます。identitydaybucket[N]truncate[N] などがあり、変換後の値は manifest ファイル内のパーティションタプルとして保持されます。

仕様は spec の進化について次のように述べています(Table Spec, Partition Evolution の項)。

When evolving a spec, changes should not cause partition field IDs to change because the partition field IDs are used as the partition tuple field IDs in manifest files.

また、manifest の解釈については次のとおりです。

Conversion uses the partition spec that was used to write the manifest file regardless of the current partition spec.

古い manifest は、それを書いた時点の spec で解釈されます。現在の spec で読み直すわけではないので、既存データを書き換える必要がありません。パーティションフィールドの ID が 1000 から始まる連番で、変更時にも既存分が維持されるのはこのためです。

hidden partitioning は、パーティション値を格納する物理列をテーブルスキーマに露出させない設計を指します。クエリはソース列(例では ts)に対する述語だけを書き、Iceberg が inclusive projection によってパーティション述語(ts_day >= day(X))を導出し、枝刈りに使います。Hive では WHERE ts >= X AND ts_day >= '2026-07-05' のように論理述語とパーティション述語の両方を書く必要があり、片方を書き忘れるとフルスキャンになるか結果が誤りました。

処理の流れ

  1. lab.eventsevent_idregionts の 3 列で、パーティションなしで作成します。

  2. region の identity パーティションを追加します(spec 1)。

with tbl.update_spec() as us:
    us.add_field("region", IdentityTransform(), "region_part")
  1. spec 1 の状態で 5 行(asia 3 行、europe 2 行)を append します。

  2. ts の day transform を追加します(spec 2)。既存の region_part は残したまま、フィールドが 1 つ増えます。

with tbl.update_spec() as us:
    us.add_field("ts", DayTransform(), "ts_day")
  1. spec 2 の状態で asia 3 行を append します。

  2. tbl.inspect.files() でデータファイルごとの spec_id を確認します。

files = tbl.inspect.files()
df = files.select(["file_path", "spec_id", "record_count"]).to_pandas()
  1. ts だけを条件にしたスキャンを実行し、パーティション列に触れずに絞り込めることを確認します。
result = tbl.scan(row_filter=f"ts >= '{day2.isoformat()}'").to_arrow()
  1. tbl.inspect.partitions() でパーティションごとの状態を表示します。

実測結果

spec の変遷は次のとおりです。

--- 最初は非パーティションで作ります
    [initial] spec-id=0
      (パーティションなし)

--- region で identity パーティションを追加します
    [spec 1] spec-id=1
      field-id=1000 source-id=2 name=region_part transform=identity

--- パーティションを進化させます: ts の day transform を追加
    [spec 2] spec-id=2
      field-id=1000 source-id=2 name=region_part transform=identity
      field-id=1001 source-id=3 name=ts_day transform=day

region_part のパーティションフィールド ID は spec 1 と spec 2 の両方で 1000 のままです。新しく追加された ts_day に 1001 が振られています。source-id はテーブルスキーマ側の field ID で、region が 2、ts が 3 に対応します。

テーブルは過去の spec もすべて保持しています。

    テーブルが保持する spec 一覧:
      spec-id=0: []
      spec-id=1: ['region_part=identity']
      spec-id=2 (default): ['region_part=identity', 'ts_day=day']

データファイルごとの spec_id です。

                                           file_path  spec_id  record_count
00000-0-0ad8c5a8-5aeb-4e43-bf6e-bcdab0350d9b.parquet        2             3
00000-0-f031aacc-a68a-4e5a-9553-43785f7dd2d7.parquet        1             2
00000-0-cc7e947e-8c17-47b4-89cf-c341ea95a580.parquet        1             3

spec 1 で書いた 2 ファイル(2 行と 3 行)はそのまま spec_id=1 として残り、spec 2 で書いた 1 ファイル(3 行)だけが spec_id=2 です。spec の変更時に既存ファイルの書き直しは起きていません。

ts のみを条件にしたスキャンの結果です。

--- hidden partitioning: クエリにパーティション列を書かない
    ts >= 2026-07-05 で 3 行

クエリで指定したのは ts に対する述語だけで、ts_day には触れていません。全 8 行のうち 2026-07-05 以降の 3 行が返りました。

パーティションごとの状態は次のとおりです。

                                    partition  spec_id  record_count  file_count
{'region_part': 'asia', 'ts_day': 2026-07-05}        2             3           1
    {'region_part': 'europe', 'ts_day': None}        1             2           1
      {'region_part': 'asia', 'ts_day': None}        1             3           1

spec 1 で書かれたファイルの ts_day は None です。書かれた時点の spec に ts_day が存在しなかったので、パーティションタプルにも値がありません。同じ region_part='asia' でも、spec_id が違えば別のパーティションエントリとして数えられています。

ここから分かること

パーティション設計は後から変えられます。日次パーティションが粗すぎる、あるいは細かすぎると分かった時点で spec を足せば、以降の書き込みから新しい粒度が適用されます。既存データを作り直す必要はありません。ただし、既存データの物理配置が新しい spec に従うわけでもないので、過去分にも新しい粒度を効かせたいなら再書き込み(rewrite)が別途必要です。

古い spec のファイルでパーティション値が None になる点は、枝刈りの効き方に影響します。ts_day が未定義のファイルは ts_day を使った絞り込みでは除外できず、ファイル単位の統計(列の min/max)に頼ることになります。spec を変えた直後は、変更前のデータに対する枝刈りが期待どおりに効かない可能性があります。

hidden partitioning によって、クエリ側はパーティション設計を知らなくて済みます。裏を返せば、パーティション設計を変えても既存のクエリを書き換える必要がありません。Hive でパーティション列がクエリ本文に埋め込まれていたのと比べると、設計変更のコストがクエリ側に波及しなくなっています。

前は 12. スキーマ進化と field ID、次は 14. タイムトラベルと branch / tag です。関連する調査内容は姉妹リポジトリの調査報告書にまとめられています。