17. PyIceberg の制約を実証する

これまでのサンプルは「動くこと」を確認してきました。samples/07_pitfalls.py は逆に「動かないこと」を確認します。PyIceberg でできないことを実際に踏んで、エラーメッセージや戻り値として記録するのが目的です。

対象は PyIceberg 0.11.1、確認日は 2026-07-17(2026-07-18 に再確認)です。ここで挙げる項目は PyIceberg の欠陥ではなく、実装の進行状況です。Iceberg の仕様は Java 実装が先行しており、Python 実装は追随している途中にあります。ただし、知らずに設計すると後から構成を組み直すことになるため、事前に把握しておく必要があります。

確かめたいこと

  • format-version 3 へのアップグレードが通らないこと
  • write.delete.mode = merge-on-read を設定しても copy-on-write で実行されること
  • equality delete が書けないだけでなく読めないこと
  • メンテナンス機能に compaction / orphan file 削除が無いこと
  • s3.path-style-access が PyIceberg に存在せず、黙って無視されること
  • upsert が SQL の MERGE INTO と同等ではないこと

背景

Iceberg のテーブル仕様は format-version で世代管理されています。v2 で position delete / equality delete による merge-on-read が入り、v3 で deletion vector と row lineage が加わりました。PyIceberg は v3 のメタデータ型(TableMetadataV3)を持っており読み取りは可能ですが、書き込みは制限されています。追跡 issue は apache/iceberg-python#1551 です。

merge-on-read(MoR)と copy-on-write(CoW)は更新の実装方式の違いです。CoW は対象行を含むデータファイルを丸ごと書き直します。MoR は delete ファイルを追加するだけで、読み取り時に適用します。書き込みが軽い代わりに読み取りが重くなり、compaction が前提になります。

equality delete は「この列がこの値の行を消す」という形式の delete ファイルです。Flink の UPSERT(write.upsert.enabled)はこの形式を使います。

処理の流れ

  1. PyIceberg のバージョンを表示し、テーブル lab.pitfalls を作って5行 append します。

  2. upgrade_table_version(3) を試みます。

with tbl.transaction() as tx:
    tx.upgrade_table_version(3)
  1. write.delete.mode = merge-on-read を設定してから delete を実行し、warnings.catch_warnings(record=True) で警告を捕捉します。そのうえで inspect.delete_files() の行数を確認します。

  2. equality delete については、書けないため実際には作れません。PyIceberg のソースにある2つの raise を提示して代替します。1つは書き込み経路の ValueError、もう1つは読み取り(REST scan planning)経路の NotImplementedError です。

  3. tbl.maintenance の公開メソッドを列挙し、compaction / orphan 系が無いことを確認します。

  4. pyiceberg.ios3.path-style-access に相当する定数が定義されていないことを、モジュールの大文字定数を走査して確認します。

has_path_style = any(
    getattr(io_mod, n, None) == "s3.path-style-access"
    for n in dir(io_mod) if n.isupper()
)
  1. tbl.upsert のシグネチャを表示し、実際に upsert を1回実行します。

実測結果

1. format-version 3

    PyIceberg バージョン: 0.11.1
--- 【落とし穴 1】 format-version 3 は読めるが書けない
    現在の format-version: 2
    → 既定は 2 です (TableProperties.DEFAULT_FORMAT_VERSION = 2)

    v3 にアップグレードしてみます:
    OK  期待どおり拒否されました: ValueError: Unsupported table format version: 3

このサンプルは当初 NotImplementedError を想定して書かれていましたが、実際にはその手前で ValueError になりました。0.11.1 のソースを追うと、v3 は二重にガードされています。

場所 例外
1 table/__init__.py L338upgrade_table_versionformat_version not in {1, 2} ValueError: Unsupported table format version: 3
1’ table/update/__init__.py L316SUPPORTED_TABLE_FORMAT_VERSION = 2 と比較) 同上
2 table/metadata.py L578TableMetadataV3.model_dump_json NotImplementedError: Writing V3 is not yet supported

手前の 1 で弾かれるため、2 には通常到達しません。到達する例外の種類は想定と異なりましたが、v3 へアップグレードできないという結論は変わりません。

型定義を見ると typedef.py L212TableVersion = Literal[1, 2, 3] は v3 を許容しているように読めます。しかし実際に書けるのは v2 までです。型定義だけを見て対応状況を判断すると誤ります。

実務上は、PyIceberg では deletion vector も row lineage も使えず、v3 を前提とした設計は現時点で成り立ちません。

2. merge-on-read

プロパティの設定自体は成功します。しかし delete の実行時に警告が出て、copy-on-write で処理されます。

    テーブルプロパティを設定しました: write.delete.mode = merge-on-read

    この状態で delete を実行します:
    OK  警告が出ました: Merge on read is not yet supported, falling back to copy-on-write
    OK  delete ファイル数: 0 (CoW なので 0 が期待値)

inspect.delete_files() が 0 行であることから、position delete が1つも書かれていないと確認できます。プロパティは受け付けられて tbl.properties にも残るため、テーブル定義だけを見ると MoR で運用しているように見えます。実際の挙動との差は警告でしか現れません。

3. equality delete

書き込みができないため、このサンプルでは実データを作れていません。ソース上の raise で示しています。

    ソース (table/__init__.py L2114):
      raise ValueError("PyIceberg does not yet support equality deletes: ...")

    ソース (table/__init__.py L1886, REST scan planning 経路):
      raise NotImplementedError(f"PyIceberg does not yet support equality deletes: {delete_file.file_path}")

上の2つはそれぞれ 書き込み経路 L2114読み取り経路 L1886 にあります。

後者が読み取りパスにあることが実務上の要点です。Flink や Spark が equality delete を書いたテーブルは、PyIceberg では開けません。この経路自体はラボ内で実行できていないため、実際の失敗を再現した結果としては未確認です。上流の書き込みエンジンが equality delete を使うかどうかは、PyIceberg を読み取り側に据える前に確認する必要があります。

4. メンテナンス機能

    MaintenanceTable のメソッド: ['expire_snapshots', 'tbl']

    OK  expire_snapshots は存在する
    OK  compaction / rewrite_data_files は存在しない
    OK  remove_orphan_files は存在しない

公開されているのは expire_snapshots だけでした。実行自体は通ります。

    expire_snapshots は使えます:
      スナップショット数: 2 -> 2
      (10年前より古いものを対象にしたので、何も消えません)

compaction が無いことは、15. メタデータ三層構造を覗く で見た小ファイル問題に直結します。あの実験では manifest ファイルのほうが data file より大きいという状態が発生していました。これを解消する手段が PyIceberg 側にありません。

5. s3.path-style-access

    実在するキー: S3_ENDPOINT = 's3.endpoint'
    実在するキー: S3_FORCE_VIRTUAL_ADDRESSING = 's3.force-virtual-addressing'
    OK  s3.path-style-access は定義されていない

s3.path-style-access は Java 版 Iceberg / Spark のキーです。PyIceberg の設定に書いてもエラーにはならず、未知のキーとして無視されます。プロパティが単なる dict 参照であるため、設定ミスが表面化しません。

一方で、MinIO や RustFS のような S3 互換ストレージに対しては、何も設定しなくても path-style になります。PyArrow の S3FileSystemforce_virtual_addressing の既定が False で、公式ドキュメントは次のように述べています。

If false, then virtual addressing is only enabled if endpoint_override is empty

s3.endpoint を設定した時点で endpoint_override が非空になるため、自動的に path-style が選ばれます。

混乱しやすいのは、同じ compose ファイルの中に2つの流儀が同居する点です。Java 側のコンテナには CATALOG_S3_PATH__STYLE__ACCESS=true が効き、Python 側には対応するキーが存在しません。

6. upsert

    upsert のシグネチャ:
      df = (必須)
      join_cols = None
      when_matched_update_all = True
      when_not_matched_insert_all = True
      case_sensitive = True
      branch = main
      snapshot_properties = {}

分岐の制御は when_matched_update_allwhen_not_matched_insert_all の真偽値2つだけです。SQL の MERGE INTO にある「WHEN MATCHED AND 条件 THEN UPDATE SET 特定列」「WHEN MATCHED THEN DELETE」「複数の WHEN 句」は表現できません。

実行自体は期待どおり動きます。

      更新: 1 行, 挿入: 1 行
 id      val
  1      NEW
  2     keep
  3 inserted

id=1 が更新され、id=3 が挿入され、id=2 はそのまま残りました。ただし内部は delete ファイルを使わない CoW であり、対象を含むデータファイルが書き直されます。

ここから分かること

PyIceberg 0.11.1 は、読み取りと軽量な書き込み、メタデータの調査に向いています。inspect.* は充実しており、15. メタデータ三層構造を覗く のような調査はこれだけで完結します。DuckDB / Polars / pandas との連携も同様です。REST カタログ仕様の学習と検証にも使えます。

一方で次の用途には現時点で適しません。

  • format-version 3 の機能(deletion vector、row lineage)を使う設計
  • Flink / Spark が equality delete を書くテーブルの読み取り
  • merge-on-read での更新(CoW に落ちる)
  • compaction や orphan file 削除を含む運用の自動化
  • 複数条件を持つ MERGE INTO 相当のロジック

現実的な構成は、PyIceberg で読み取りと軽い書き込みを担い、メンテナンスと重い DML は Spark に任せる形になります。「Python だけで Iceberg を運用する」は、少なくとも 0.11.1 の時点では成立しません。ブランチ運用についても、14. タイムトラベルと branch / tag で触れたとおり fast-forward が Spark procedure であるため、同じ結論になります。

これらの状況は実装の進行に伴って変わります。バージョンを上げる際は、ここで確認した各項目を再度実行して現況を確かめてください。運用面の背景は姉妹リポジトリの調査報告書(https://dobachi.github.io/iceberg-research/ )にまとめてあります。