Skip to content

Transaction.table_metadata deep-copies the whole table metadata on every read, making commits quadratic in snapshot count #4294

Description

@raufer

Apache Iceberg version

0.12.0 (latest release). main at e3564a5 has the same code.

Please describe the bug

Transaction.table_metadata recomputes on every read:

@property
def table_metadata(self) -> TableMetadata:
    return update_table_metadata(self._table.metadata, self._updates)

update_table_metadata ends in new_metadata.model_copy(deep=True), so every read deep-copies the whole TableMetadata, snapshot list included. One commit reads the property tens of times, so its cost grows linearly with the snapshots the table keeps, whatever rows it writes. Each commit also adds a snapshot, so N commits to one table cost O(N²) in total.

#2674 and #3301 hoisted repeated reads out of loops. The property itself is still uncached.

What we measured

One live table in a development environment: 126 partitions, 1.09M rows, snapshots expired after 24 h. Committed to about 225 times an hour, it reached thousands of snapshots.

Measurement Result
Metadata at about 2,770 snapshots 2.6 MB
Metadata at 5,410 snapshots 5.1 MB
One deep copy at 5,410 snapshots, on a laptop 0.6–1.5 s
Reads of the property in one dynamic_partition_overwrite of 500 rows 51
deepcopy calls in that commit at 192 snapshots (cProfile) 865k
One commit, local SQL catalog, at 51 / 144 / 184 snapshots 0.6 s / 2.0 s / 3.3 s
The same data in 20 commits / in one commit, local SQL catalog 66 s / 5.5 s

cProfile puts most of the commit in that model_copy(deep=True). We did not time a whole commit at the 2,770 or 5,410 depths.

We work around it by batching commits and expiring snapshots. Neither removes the repeated copies.

Reproduction

Each timed commit appends one row. reads counts calls to update_table_metadata from pyiceberg.table.

uv run --isolated --no-project --python 3.12 --with 'pyiceberg[sql-sqlite,pyarrow]==0.12.0' python repro.py
import tempfile
import time
from unittest import mock

import pyarrow as pa
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.schema import Schema
from pyiceberg.table import update_table_metadata
from pyiceberg.transforms import IdentityTransform
from pyiceberg.types import LongType, NestedField, StringType

warehouse = tempfile.mkdtemp()
catalog = SqlCatalog("repro", uri=f"sqlite:///{warehouse}/catalog.db", warehouse=f"file://{warehouse}")
catalog.create_namespace("ns")
table = catalog.create_table(
    "ns.t",
    schema=Schema(
        NestedField(1, "key", StringType(), required=False),
        NestedField(2, "value", LongType(), required=False),
    ),
    partition_spec=PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="key")),
)


def rows(key: str, n: int) -> pa.Table:
    return pa.table({"key": pa.array([key] * n, pa.string()), "value": pa.array(range(n), pa.int64())})


for target in (0, 100, 200, 400, 800):
    while len(table.metadata.snapshots) < target:
        table.append(rows("other", 1))
    size = len(table.metadata.model_dump_json())
    snapshots = len(table.metadata.snapshots)
    with mock.patch("pyiceberg.table.update_table_metadata", wraps=update_table_metadata) as reads:
        start = time.perf_counter()
        table.append(rows("timed", 1))
        elapsed = time.perf_counter() - start
    print(f"snapshots={snapshots:4d}  metadata={size / 1e6:.2f} MB  append={elapsed * 1000:7.1f} ms  reads={reads.call_count}", flush=True)

One run, one timed append per depth, Python 3.12.11, pyarrow 26.0.1, on a laptop:

snapshots=   0  metadata=0.00 MB  append=   80.0 ms  reads=19
snapshots= 100  metadata=0.09 MB  append=   32.9 ms  reads=22
snapshots= 200  metadata=0.15 MB  append=   55.8 ms  reads=22
snapshots= 400  metadata=0.28 MB  append=  158.8 ms  reads=22
snapshots= 800  metadata=0.55 MB  append=  315.2 ms  reads=22

The fix exists

Two PRs fix this, and the stale bot closed both unmerged:

Could a maintainer reopen #3784, or say what path would land it?

Willingness to contribute

  • I can contribute a fix for this bug independently
  • I would be willing to contribute a fix for this bug with guidance from the Iceberg community
  • I cannot contribute a fix for this bug at this time

Activity

  1. dedyoc commented on Nov 7, 2026

    @dedyoc

    Hello,

    I'd like to work on this and wanted to check the approach first.

    So before revisiting the cache approach from #3302, I'd open a smaller PR with the alternative @geruh suggested in there:

    @property
    def table_metadata(self) -> TableMetadata:
      if not self._updates:
        return self._table.metadata
      return update_table_metadata(self._table.metadata, self._updates)

    This change is simple but effective enough. In a plain append, the table_metadata reads happen before anything is staged, so they no longer deep copy. I tested the issue's repro.py on main, append at 800 snapshots goes down from 541ms to 82ms.

    This doesn't cover transactions that already have updates staged. If you'd like that handled too, I can bring the caching from #3302/#3784 into this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions