You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Replace PyIceberg's Cython/pure-Python Avro reader for manifest lists and manifests with the iceberg-rust reader, exposed through pyiceberg-core. This removes the Cython build step and the per-platform wheel matrix from PyIceberg, and pushes manifest deserialization into Rust.
This issue tracks the current state, what stalled the previous attempt, and the concrete steps to finish it.
Current state
iceberg-rust / pyiceberg-core (done)
The binding exists and has shipped in every pyiceberg-core release since 0.7.0. It lives in bindings/python/src/manifest.rs and bindings/python/src/data_file.rs and exposes:
pyiceberg_core.manifest.read_manifest_list(bytes) -> ManifestList with .entries() -> list[ManifestFile]
pyiceberg_core.manifest.read_manifest_entries(bytes) -> Manifest with .entries() -> list[ManifestEntry]
Getter-only wrapper classes for ManifestFile, ManifestEntry, DataFile, FieldSummary, and PrimitiveLiteral.
Core changes that were required for the binding are merged: partition types are bound lazily inside iceberg-rust (no Python callback), V1 manifest lists project to V2 with content defaulted to DATA, and a V2 reader can read V1 manifests.
iceberg-rust core already parses V3 fields (first_row_id, referenced_data_file, content_offset, content_size_in_bytes on data files; first_row_id on manifest files), but the Python wrapper classes do not expose them yet.
The binding has a single Python test (bindings/python/tests/test_manifest.py) that reads one V2 manifest and converts each entry back into PyIceberg's ManifestEntry / DataFile in Python.
PyIceberg (not done)
pyiceberg-core is an optional extra (pyiceberg[pyiceberg-core]) and is only used for partition transforms and the DataFusion table provider today. On iceberg-rust main the DataFusion table provider has been removed from the binding (#3143, 2026-09-03), see Side findings.
ManifestFile.fetch_manifest_entry and read_manifest_list still go through pyiceberg.avro.file.AvroFile with the Cython decoder_fast decoder, reading with the V2 projection on 0.12.0 and the V3 projection on main (DEFAULT_READ_VERSION = 3 since 2026-08-31, #3690).
A previous PR (Use Iceberg-Rust for parsing the ManifestList and Manifests, https://github.com/apache/iceberg-python/pull/2004) wired both read paths to the binding and was closed as stale in March 2026 without being merged.
Why the previous attempt stalled
Performance regression (measured, see the benchmark comment below). On manifests written by PyIceberg's own writer (12-column schema, full column stats), the gap is 5x to 6x end to end as shipped, and 3x to 4x when the wheel is built with opt-level = 3 instead of "z". The ratio is workload-dependent (a 4-column schema on a Linux box gives about 3x) but it is a regression at every size measured. The cost splits into two independent buckets:
iceberg-rust parse (~36 us / entry). About 82% is inside apache-avro before any iceberg-rust code runs: decoding into the generic apache_avro::Value tree (~13 us / entry) and then a second full tree walk in Value::resolve (~17 us / entry) because the generated reader schema is never == the writer schema, so schema resolution always runs. Serde and Datum conversion are the remaining ~7 us / entry.
Python-side conversion (~10 us / entry). Rebuilding each entry as a PyIceberg DataFile / ManifestEntry via from_args costs as much as the entire Cython path (~11 us / entry). PyO3 getter overhead itself is only ~1 us / entry.
Decimal partition values.PrimitiveLiteral exposes decimals as a bare int (the underlying i128) with no scale, so decimal identity/truncate partition tests had to be commented out. A PyDatum that carries the Iceberg type alongside the value has been proposed on the iceberg-rust side but is not implemented.
Reader depends on the manifest's embedded schema key. iceberg-rust parses each manifest's own schema and partition-spec Avro key-value metadata and hard-fails without them. PyIceberg's current reader does not depend on those keys. An iceberg-rust PR to derive schema/spec from table metadata (like Java's specsById, #2683) is open but unmerged.
Known gaps in the binding
ManifestFile.partitions calls unwrap() on an optional field and panics when partition summaries are absent (a valid state). Tracking issue #2883 is open; the fix PR #2886 was closed unmerged by the stale bot on 2026-09-03 and can be reopened. Reproduced with an Avro nullpartitions field; an unpartitioned spec ([]) is fine.
read_manifest_list and read_manifest_entries call unwrap() on parse errors, so a corrupt or unexpected file raises pyo3_runtime.PanicException instead of a normal Python exception. PanicException derives from BaseException, so PyIceberg cannot catch it; every parse-time failure below surfaces this way.
Reading a spec-conformant uuid partition value (fixed(16) + logicalType: uuid, what PyIceberg and Java write) panics: apache-avro 0.21 mis-parses that writer schema and decodes it as a length-prefixed value. Needs the apache-avro 0.22 upgrade. The write side (iceberg-rust cannot write uuid partitions today) is a separate task, see Side findings.
timestamp_ns / timestamptz_ns partition values panic at 0.10.1 (no RawLiteralEnum::Long arm). Fixed on main by https://github.com/apache/iceberg-rust/pull/3091 (2026-09-07), not released; verified with a binding built from main (audit below).
Bounds are decoded to Datum and re-encoded by the getters, not passed through: decimal bounds are normalized to the spec's minimal length (value-preserving, and what Java's and PyIceberg's own encoders already emit), bounds for field ids missing from the manifest's embedded schema are dropped, and an invalid-width bound panics the read. Absent stats maps come back as {} where PyIceberg has None.
read_manifest_list hard-codes FormatVersion::V2, so first_row_id from V3 manifest lists is dropped.
DataFile does not expose the four V3 fields or spec_id.
Sequence-number / snapshot-id inheritance (inherit_data) is pub(crate) in iceberg-rust, so it cannot be done in Rust from the binding and has to be redone in Python.
Test coverage: PyIceberg has 23 manifest tests in tests/utils/test_manifest.py on main (V1, V2, V3 fields including first_row_id, inheritance, caching; 20 on 0.12.0); nothing tests equality_ids written as array<long>, which PyIceberg still does. The binding has one.
Type round-trip audit, 2026-09-07: released (pyiceberg 0.12.0, pyiceberg-core 0.10.1) and main (iceberg-python 9299bdb, pyiceberg-core built from iceberg-rust 28ede50)
Repro (three Python probes and two Rust probes, with a README): https://github.com/kevinjqliu/iceberg-python/tree/claude/pyiceberg-manifest-roundtrip-3b2kgq/dev/manifest_roundtrip
One identity-partitioned column per primitive type, format v2, written with write_manifest, read back with read_manifest_entries(bs).entries()[0].data_file.partition[0].value(), fastavro as the on-disk control, except BaseException because the failures are Rust panics.
OK boolean wrote True read True bool
OK int wrote 42 read 42 int
OK long wrote 1099511627776 read 1099511627776 int
OK float wrote 1.5 read 1.5 float
OK double wrote 2.25 read 2.25 float
OK date wrote 19000 read 19000 int
OK time wrote 12345678901 read 12345678901 int
OK timestamp wrote 1600000000000000 read 1600000000000000 int
OK timestamptz wrote 1600000000000000 read 1600000000000000 int
OK string wrote hello read hello str
PANIC uuid wrote 12345678-1234-5678-1234-567812345678 read pyo3_runtime.PanicException
OK fixed[16] wrote b'0123456789abcdef' read b'0123456789abcdef' bytes
OK binary wrote b'\x00\x01\x02' read b'\x00\x01\x02' bytes
DIFF decimal(5,2) wrote 123.45 read 12345 int
DIFF decimal(9,2) wrote 1234567.89 read 123456789 int
DIFF decimal(18,6) wrote 123456789012.345678 read 123456789012345678 int
DIFF decimal(38,10) wrote 1234567890123456789012345678.1234567890 read 12345678901234567890123456781234567890 int
PANIC timestamp_ns wrote 1600000000000000000 read pyo3_runtime.PanicException
PANIC timestamptz_ns wrote 1600000000000000000 read pyo3_runtime.PanicException
All three panics are the Manifest::parse_avro(bs).unwrap() at bindings/python/src/manifest.rs:201:
uuid called `Result::unwrap()` on an `Err` value: DataInvalid => Failure in conversion with avro
Source: Failed to convert &str to UUID: invalid character: found `V` at 2
timestamp_ns called `Result::unwrap()` on an `Err` value: DataInvalid => Unable to convert raw literal (long) fail convert to type timestamp_ns for: type mismatch
timestamptz_ns called `Result::unwrap()` on an `Err` value: DataInvalid => Unable to convert raw literal (long) fail convert to type timestamptz_ns for: type mismatch
lower_bounds on an unpartitioned manifest (the 4-byte decimal input was hand-supplied; PyIceberg's own encoder already emits b'\x01'):
field 1 decimal(9,2) wrote b'\x00\x00\x00\x01' disk(fastavro) b'\x00\x00\x00\x01' read b'\x01' ALTERED
field 2 int wrote b'\x01\x00\x00\x00' disk(fastavro) b'\x01\x00\x00\x00' read b'\x01\x00\x00\x00' OK
field 3 string wrote b'abc' disk(fastavro) b'abc' read b'abc' OK
Same files through iceberg-rust main directly (cargo test probe): the ns manifests parse (#3091), uuid fails identically (apache-avro 0.21), the decimal bound comes back as b'\x01' from core Datum::to_bytes, and a manifest list with a null partitions field parses to None in core, so that panic is only the binding's unwrap().
Manifest-list side needs no work for V2 (V3 first_row_id is the gap above): the full ManifestFile surface and FieldSummary (contains_null, contains_nan, lower_bound, upper_bound) round-trip byte-identical, including a two-field spec with bucket[4], a null partition value, and a delete manifest. key_metadata was only tested as None.
Main against main: iceberg-python main (9299bdb, Cython decoder, V3 read projection) writing, a pyiceberg-core wheel built from iceberg-rust main (28ede50, includes #3143, so no pyiceberg_core.datafusion module) reading. Identical to the release run except timestamp_ns / timestamptz_ns now round-trip as int (#3091). Still present on main: the uuid panic, bare-int decimals at every precision, PanicException on any parse error, the bounds re-encode with {} for absent maps, the null-partitions panic, and no V3 fields or spec_id on DataFile. PyIceberg main's own reader reads the uuid and decimal manifests and still fails on the ns ones.
Plan
Phase 1: make the Rust path at least as fast as Cython (iceberg-rust)
Add a reproducible benchmark: manifests with 1K to 200K entries, AvroFile vs pyiceberg_core.manifest, end to end including conversion to PyIceberg objects. Results and script are in the comment below.
Build the wheel with opt-level = 3 instead of "z". Measured 1.6x to 1.8x on this path. Re-check the binary-size trade-off that motivated "z".
Stop decoding manifest entries through apache_avro::Value + Value::resolve + serde from_value. Options: decode directly from the writer schema into ManifestEntry, or at minimum skip Reader::with_schema resolution when the writer schema is compatible and apply projection defaults (V3 fields, equality_ids int vs long, optional partition fields) in the serde layer. This is the ~30 us / entry bucket. In progress: the first iceberg-rust PR (byte-level serde reader, see the deferred-list comment below) does this and also carries the apache-avro 0.22 upgrade that the uuid read fix needs.
Move filtering into Rust: read_manifest_entries(bs, discard_deleted=True) so DELETED entries never cross the boundary.
Move inheritance into Rust: expose a public inherit step (or a read_manifest_entries(bs, manifest_file) variant) so snapshot_id, sequence_number, file_sequence_number, and spec_id are already populated.
Stop building one Python object per entry. Return Arrow (a RecordBatch in the manifest-entry schema, via the existing pyarrow feature) or construct the PyIceberg DataFile objects directly from Rust in one pass. This is the ~10 us / entry bucket and is required for parity even with a perfect Rust parser. inspect.entries() / inspect.files() would benefit directly from the Arrow form.
Gate: no regression versus Cython at 1K, 10K, 50K, and 200K entries.
Phase 2: close the binding gaps (iceberg-rust)
Return None from ManifestFile.partitions when summaries are absent (reopen #2886).
Replace unwrap() in both read functions with proper PyErr conversion (reuse bindings/python/src/error.rs). Robustness, not ergonomics: one bad manifest is unrecoverable from Python today.
Add a format_version argument (or auto-detect) to read_manifest_list and expose first_row_id.
Expose first_row_id, referenced_data_file, content_offset, content_size_in_bytes, and spec_id on DataFile.
Decide the literal representation for partition values: either a typed PyDatum (type + value, with decimal scale) or return the raw serialized bytes and let PyIceberg decode with its existing from_bytes. Unblocks decimal partitions.
Read uuid partition values: upgrade to apache-avro 0.22 (UuidSchema::Fixed) and add a test on a PyIceberg- or Java-written uuid partition. Separate from the literal-representation decision above; the value never decodes today.
timestamp_ns / timestamptz_ns partition values: already fixed on main (#3091); re-run the round-trip audit against the release that carries it.
Match partition fields by field-id before name in the reader. Java sanitizes Avro field names that are not valid identifiers and records the original in a field-id attribute; the current reader matches by name and silently drops those values (deferred list below). PyIceberg's reader resolves by field id.
Accept equality_ids written as array<long> in the byte-level reader. PyIceberg still writes long; the spec and other writers use int. Needed for the Phase 3 test below.
Decide whether bounds for field ids absent from the embedded schema are dropped (today) or passed through as bytes like PyIceberg's reader does.
Port PyIceberg's manifest read tests into bindings/python/tests/test_manifest.py (V1, V2, V3 fields, missing partition summaries, legacy equality_ids), plus the audit inputs: a null partitions manifest-list field, uuid and timestamp_ns partition values, an invalid-width bound, a bound for a field id absent from the embedded schema, and corrupt input.
Decide whether to wait for the "derive schema/spec from table metadata" change in iceberg-rust (#2683) or accept the schema key dependency for now (Java, PyIceberg and iceberg-rust all emit it; other writers not checked).
Phase 3: wire it into PyIceberg (iceberg-python)
Revive the consumer change against current main: read_manifest_list and ManifestFile.fetch_manifest_entry call the binding when pyiceberg_core is importable, and fall back to AvroFile otherwise. Keep discard_deleted and the ManifestFile cache behaviour.
Construct PyIceberg objects via DataFile.from_args / ManifestEntry.from_args / ManifestFile.from_args matching DEFAULT_READ_VERSION (V3 on main since #3690, V2 on 0.12.0). On main the Cython path already fills first_row_id, referenced_data_file, content_offset and content_size_in_bytes, so exposing them on the binding's DataFile (Phase 2) is required for parity, not optional; until then the conversion passes None.
Add a test that reads a PyIceberg-written manifest with equality_ids as array<long> through both paths; nothing covers it today and the Rust byte-level reader does not narrow integers.
Run the integration suite (test_inspect_table, test_writes, test_partitioning_key) against both paths; the earlier PR had to relax dict-ordering assertions and skip decimal cases, and those relaxations should be removed once Phase 2 lands. The test_inspect_files relaxation in that PR (list vs None) is the {}-for-absent-maps difference: normalize to None in the conversion instead of relaxing again, and keep a byte-exact bounds assertion on a PyIceberg-written table.
Gate on the benchmark from Phase 1: no regression versus Cython on large manifests.
Bump the pyiceberg-core lower bound to the release that contains Phase 1 and 2.
Phase 4: cleanup (iceberg-python)
Make pyiceberg-core a required dependency (or keep the fallback and remove Cython only after one release of soak time).
Wheel coverage before it can be required: pyiceberg-core 0.10.1 ships manylinux x86_64 / aarch64 / armv7l, macOS universal2 and win_amd64 (abi3, cp310+) but no musllinux wheels, while pyiceberg ships musllinux x86_64 and aarch64. Either add musllinux wheels to pyiceberg-core or keep the fallback on those platforms.
Size and version coupling: 0.10.1 wheels are 12 to 25 MB because they bundle datafusion-ffi, and the extra is pinned >=0.10.1,<0.11.0 because the DataFusion FFI ABI must match majors. Already resolved on iceberg-rust main by #3143 (DataFusion, OpenDAL and Tokio dropped from the binding; wheel 14 MB to 1.1 MB), so the next pyiceberg-core release removes both the size and the reason for the pin. PyIceberg's to_datafusion import has to move at the same time (Side findings).
Delete pyiceberg/avro/decoder_fast.pyx, the cythonize step in setup.py, and the per-platform wheel matrix in the release workflow.
Remove the now-unused Avro reader code paths (read_types / read_enums plumbing) once nothing else depends on them. The Avro writer stays until manifest writing is also delegated.
Side findings (not blockers, worth separate fixes)
PyIceberg writes one Avro block per manifest entry.ManifestWriter.add_entry calls write_block([entry]), so every entry gets its own deflate stream and sync marker. Re-encoding a 10K-entry manifest with 64 KB blocks (Java's default) made it 5x smaller (3.6 MB to 0.7 MB) and about 1.5x faster to read for both the Cython and the iceberg-rust decoder on the 12-column benchmark; on a 4-column schema the size win holds (6x) but the read speedup is only about 1.1x. Details and numbers in the comment below. Fix: buffer entries in ManifestWriter and flush blocks at a size threshold.
PyIceberg cannot read its own timestamp_ns / timestamptz_ns manifests.pyiceberg/utils/schema_conversion.py writes {"type": "long", "logicalType": "timestamp-nanos", "adjust-to-utc": ...} but _convert_logical_type only handles timestamp-micros, so reading fails with ValueError: Unknown logical/physical type combination. Reproduced on 0.12.0 and on main (7539661). Needs its own iceberg-python issue; independent of this epic, but V3 ns partition tests cannot pass on either path until it is fixed.
The next pyiceberg-core release removes pyiceberg_core.datafusion. iceberg-rust #3143 (2026-09-03) dropped the DataFusion table provider from the binding; a wheel built from main has no pyiceberg_core.datafusion module. PyIceberg main still does from pyiceberg_core.datafusion import IcebergDataFusionTable in pyiceberg/table/__init__.py for to_datafusion, so it breaks on that release unless PyIceberg adapts first. Needs its own iceberg-python issue; unrelated to manifests, but it gates bumping the pyiceberg-core lower bound in Phase 3.
iceberg-rust cannot write uuid partition values.ManifestWriter::write_manifest_file fails with Could not find matching type in UnionSchema { schemas: [Null, Uuid], ... } for Bytes([...]) (iceberg-rust #2913, open; reproduced on main): the literal is serialized as 16 raw bytes and apache-avro 0.21 rejects them under Schema::Uuid. The mapping in crates/iceberg/src/avro/schema.rs (PrimitiveType::Uuid => AvroSchema::Uuid) would emit {"type": "string", "logicalType": "uuid"}; the spec and Java's TypeToSchema.UUID_SCHEMA use fixed(16) + logicalType: uuid. Same item as "Uuid as fixed[16]" in the deferred list below; iceberg-rust #2916 (draft) emits Fixed(16) but without the logicalType attribute. Write side only; the read side is the uuid gap above.
apache-avro 0.22 upgrade in iceberg-rust. 0.21 parses fixed(16) + logicalType: uuid into a string-shaped Schema::Uuid and decodes it as a length-prefixed value, which is the uuid read panic above; 0.22 adds UuidSchema::{String, Bytes, Fixed} and decodes the Fixed form as 16 raw bytes. Tracked upstream as iceberg-rust #3063 and part of the first reader PR per the deferred list. The uuid read has not been verified against a 0.22 build.
Open questions
Should the write side (ManifestWriter, ManifestListWriter) move to iceberg-rust at the same time, so PyIceberg does not need to keep two representations in sync? The previous attempt deliberately kept scope to reads.
Is a columnar (Arrow) interface a better long-term contract between the two projects than one pyclass per entry? It sidesteps most of the per-object overhead and matches how inspect metadata tables are consumed.
Is the raw-bytes literal option viable? The binding never holds raw single-value bytes (partition values are decoded to typed literals, bounds are re-encoded through Datum::to_bytes), so it is a second serialization boundary with the same decode dependency as PyDatum.
Goal
Replace PyIceberg's Cython/pure-Python Avro reader for manifest lists and manifests with the iceberg-rust reader, exposed through
pyiceberg-core. This removes the Cython build step and the per-platform wheel matrix from PyIceberg, and pushes manifest deserialization into Rust.This issue tracks the current state, what stalled the previous attempt, and the concrete steps to finish it.
Current state
iceberg-rust / pyiceberg-core (done)
pyiceberg-corerelease since 0.7.0. It lives inbindings/python/src/manifest.rsandbindings/python/src/data_file.rsand exposes:pyiceberg_core.manifest.read_manifest_list(bytes) -> ManifestListwith.entries() -> list[ManifestFile]pyiceberg_core.manifest.read_manifest_entries(bytes) -> Manifestwith.entries() -> list[ManifestEntry]ManifestFile,ManifestEntry,DataFile,FieldSummary, andPrimitiveLiteral.contentdefaulted toDATA, and a V2 reader can read V1 manifests.first_row_id,referenced_data_file,content_offset,content_size_in_byteson data files;first_row_idon manifest files), but the Python wrapper classes do not expose them yet.bindings/python/tests/test_manifest.py) that reads one V2 manifest and converts each entry back into PyIceberg'sManifestEntry/DataFilein Python.PyIceberg (not done)
pyiceberg-coreis an optional extra (pyiceberg[pyiceberg-core]) and is only used for partition transforms and the DataFusion table provider today. On iceberg-rustmainthe DataFusion table provider has been removed from the binding (#3143, 2026-09-03), see Side findings.ManifestFile.fetch_manifest_entryandread_manifest_liststill go throughpyiceberg.avro.file.AvroFilewith the Cythondecoder_fastdecoder, reading with the V2 projection on 0.12.0 and the V3 projection onmain(DEFAULT_READ_VERSION = 3since 2026-08-31,#3690).Use Iceberg-Rust for parsing the ManifestList and Manifests,https://github.com/apache/iceberg-python/pull/2004) wired both read paths to the binding and was closed as stale in March 2026 without being merged.Why the previous attempt stalled
opt-level = 3instead of"z". The ratio is workload-dependent (a 4-column schema on a Linux box gives about 3x) but it is a regression at every size measured. The cost splits into two independent buckets:apache-avrobefore any iceberg-rust code runs: decoding into the genericapache_avro::Valuetree (~13 us / entry) and then a second full tree walk inValue::resolve(~17 us / entry) because the generated reader schema is never==the writer schema, so schema resolution always runs. Serde andDatumconversion are the remaining ~7 us / entry.DataFile/ManifestEntryviafrom_argscosts as much as the entire Cython path (~11 us / entry). PyO3 getter overhead itself is only ~1 us / entry.PrimitiveLiteralexposes decimals as a bareint(the underlyingi128) with no scale, so decimal identity/truncate partition tests had to be commented out. APyDatumthat carries the Iceberg type alongside the value has been proposed on the iceberg-rust side but is not implemented.schemakey. iceberg-rust parses each manifest's ownschemaandpartition-specAvro key-value metadata and hard-fails without them. PyIceberg's current reader does not depend on those keys. An iceberg-rust PR to derive schema/spec from table metadata (like Java'sspecsById,#2683) is open but unmerged.Known gaps in the binding
ManifestFile.partitionscallsunwrap()on an optional field and panics when partition summaries are absent (a valid state). Tracking issue#2883is open; the fix PR#2886was closed unmerged by the stale bot on 2026-09-03 and can be reopened. Reproduced with an Avronullpartitionsfield; an unpartitioned spec ([]) is fine.read_manifest_listandread_manifest_entriescallunwrap()on parse errors, so a corrupt or unexpected file raisespyo3_runtime.PanicExceptioninstead of a normal Python exception.PanicExceptionderives fromBaseException, so PyIceberg cannot catch it; every parse-time failure below surfaces this way.fixed(16)+logicalType: uuid, what PyIceberg and Java write) panics: apache-avro 0.21 mis-parses that writer schema and decodes it as a length-prefixed value. Needs the apache-avro 0.22 upgrade. The write side (iceberg-rust cannot write uuid partitions today) is a separate task, see Side findings.timestamp_ns/timestamptz_nspartition values panic at 0.10.1 (noRawLiteralEnum::Longarm). Fixed onmainbyhttps://github.com/apache/iceberg-rust/pull/3091(2026-09-07), not released; verified with a binding built frommain(audit below).Datumand re-encoded by the getters, not passed through: decimal bounds are normalized to the spec's minimal length (value-preserving, and what Java's and PyIceberg's own encoders already emit), bounds for field ids missing from the manifest's embeddedschemaare dropped, and an invalid-width bound panics the read. Absent stats maps come back as{}where PyIceberg hasNone.read_manifest_listhard-codesFormatVersion::V2, sofirst_row_idfrom V3 manifest lists is dropped.DataFiledoes not expose the four V3 fields orspec_id.inherit_data) ispub(crate)in iceberg-rust, so it cannot be done in Rust from the binding and has to be redone in Python.tests/utils/test_manifest.pyonmain(V1, V2, V3 fields includingfirst_row_id, inheritance, caching; 20 on 0.12.0); nothing testsequality_idswritten asarray<long>, which PyIceberg still does. The binding has one.Type round-trip audit, 2026-09-07: released (pyiceberg 0.12.0, pyiceberg-core 0.10.1) and main (iceberg-python 9299bdb, pyiceberg-core built from iceberg-rust 28ede50)
Repro (three Python probes and two Rust probes, with a README):
https://github.com/kevinjqliu/iceberg-python/tree/claude/pyiceberg-manifest-roundtrip-3b2kgq/dev/manifest_roundtripOne identity-partitioned column per primitive type, format v2, written with
write_manifest, read back withread_manifest_entries(bs).entries()[0].data_file.partition[0].value(), fastavro as the on-disk control,except BaseExceptionbecause the failures are Rust panics.All three panics are the
Manifest::parse_avro(bs).unwrap()atbindings/python/src/manifest.rs:201:lower_boundson an unpartitioned manifest (the 4-byte decimal input was hand-supplied; PyIceberg's own encoder already emitsb'\x01'):Same files through iceberg-rust
maindirectly (cargo testprobe): the ns manifests parse (#3091), uuid fails identically (apache-avro 0.21), the decimal bound comes back asb'\x01'from coreDatum::to_bytes, and a manifest list with a nullpartitionsfield parses toNonein core, so that panic is only the binding'sunwrap().Manifest-list side needs no work for V2 (V3
first_row_idis the gap above): the fullManifestFilesurface andFieldSummary(contains_null,contains_nan,lower_bound,upper_bound) round-trip byte-identical, including a two-field spec withbucket[4], a null partition value, and a delete manifest.key_metadatawas only tested asNone.Main against main: iceberg-python
main(9299bdb, Cython decoder, V3 read projection) writing, apyiceberg-corewheel built from iceberg-rustmain(28ede50, includes#3143, so nopyiceberg_core.datafusionmodule) reading. Identical to the release run excepttimestamp_ns/timestamptz_nsnow round-trip asint(#3091). Still present onmain: the uuid panic, bare-intdecimals at every precision,PanicExceptionon any parse error, the bounds re-encode with{}for absent maps, the null-partitionspanic, and no V3 fields orspec_idonDataFile. PyIcebergmain's own reader reads the uuid and decimal manifests and still fails on the ns ones.Plan
Phase 1: make the Rust path at least as fast as Cython (iceberg-rust)
AvroFilevspyiceberg_core.manifest, end to end including conversion to PyIceberg objects. Results and script are in the comment below.opt-level = 3instead of"z". Measured 1.6x to 1.8x on this path. Re-check the binary-size trade-off that motivated"z".apache_avro::Value+Value::resolve+ serdefrom_value. Options: decode directly from the writer schema intoManifestEntry, or at minimum skipReader::with_schemaresolution when the writer schema is compatible and apply projection defaults (V3 fields,equality_idsint vs long, optional partition fields) in the serde layer. This is the ~30 us / entry bucket. In progress: the first iceberg-rust PR (byte-level serde reader, see the deferred-list comment below) does this and also carries the apache-avro 0.22 upgrade that the uuid read fix needs.read_manifest_entries(bs, discard_deleted=True)soDELETEDentries never cross the boundary.inheritstep (or aread_manifest_entries(bs, manifest_file)variant) sosnapshot_id,sequence_number,file_sequence_number, andspec_idare already populated.RecordBatchin the manifest-entry schema, via the existingpyarrowfeature) or construct the PyIcebergDataFileobjects directly from Rust in one pass. This is the ~10 us / entry bucket and is required for parity even with a perfect Rust parser.inspect.entries()/inspect.files()would benefit directly from the Arrow form.Phase 2: close the binding gaps (iceberg-rust)
NonefromManifestFile.partitionswhen summaries are absent (reopen#2886).unwrap()in both read functions with properPyErrconversion (reusebindings/python/src/error.rs). Robustness, not ergonomics: one bad manifest is unrecoverable from Python today.format_versionargument (or auto-detect) toread_manifest_listand exposefirst_row_id.first_row_id,referenced_data_file,content_offset,content_size_in_bytes, andspec_idonDataFile.PyDatum(type + value, with decimal scale) or return the raw serialized bytes and let PyIceberg decode with its existingfrom_bytes. Unblocks decimal partitions.UuidSchema::Fixed) and add a test on a PyIceberg- or Java-written uuid partition. Separate from the literal-representation decision above; the value never decodes today.timestamp_ns/timestamptz_nspartition values: already fixed onmain(#3091); re-run the round-trip audit against the release that carries it.field-idbefore name in the reader. Java sanitizes Avro field names that are not valid identifiers and records the original in afield-idattribute; the current reader matches by name and silently drops those values (deferred list below). PyIceberg's reader resolves by field id.equality_idswritten asarray<long>in the byte-level reader. PyIceberg still writeslong; the spec and other writers useint. Needed for the Phase 3 test below.schemaare dropped (today) or passed through as bytes like PyIceberg's reader does.bindings/python/tests/test_manifest.py(V1, V2, V3 fields, missing partition summaries, legacyequality_ids), plus the audit inputs: a nullpartitionsmanifest-list field, uuid andtimestamp_nspartition values, an invalid-width bound, a bound for a field id absent from the embedded schema, and corrupt input.#2683) or accept theschemakey dependency for now (Java, PyIceberg and iceberg-rust all emit it; other writers not checked).Phase 3: wire it into PyIceberg (iceberg-python)
main:read_manifest_listandManifestFile.fetch_manifest_entrycall the binding whenpyiceberg_coreis importable, and fall back toAvroFileotherwise. Keepdiscard_deletedand theManifestFilecache behaviour.DataFile.from_args/ManifestEntry.from_args/ManifestFile.from_argsmatchingDEFAULT_READ_VERSION(V3 onmainsince#3690, V2 on 0.12.0). Onmainthe Cython path already fillsfirst_row_id,referenced_data_file,content_offsetandcontent_size_in_bytes, so exposing them on the binding'sDataFile(Phase 2) is required for parity, not optional; until then the conversion passesNone.equality_idsasarray<long>through both paths; nothing covers it today and the Rust byte-level reader does not narrow integers.test_inspect_table,test_writes,test_partitioning_key) against both paths; the earlier PR had to relax dict-ordering assertions and skip decimal cases, and those relaxations should be removed once Phase 2 lands. Thetest_inspect_filesrelaxation in that PR (list vsNone) is the{}-for-absent-maps difference: normalize toNonein the conversion instead of relaxing again, and keep a byte-exact bounds assertion on a PyIceberg-written table.pyiceberg-corelower bound to the release that contains Phase 1 and 2.Phase 4: cleanup (iceberg-python)
pyiceberg-corea required dependency (or keep the fallback and remove Cython only after one release of soak time).pyiceberg-core0.10.1 ships manylinux x86_64 / aarch64 / armv7l, macOS universal2 and win_amd64 (abi3, cp310+) but no musllinux wheels, whilepyicebergships musllinux x86_64 and aarch64. Either add musllinux wheels to pyiceberg-core or keep the fallback on those platforms.datafusion-ffi, and the extra is pinned>=0.10.1,<0.11.0because the DataFusion FFI ABI must match majors. Already resolved on iceberg-rustmainby#3143(DataFusion, OpenDAL and Tokio dropped from the binding; wheel 14 MB to 1.1 MB), so the next pyiceberg-core release removes both the size and the reason for the pin. PyIceberg'sto_datafusionimport has to move at the same time (Side findings).pyiceberg/avro/decoder_fast.pyx, thecythonizestep insetup.py, and the per-platform wheel matrix in the release workflow.read_types/read_enumsplumbing) once nothing else depends on them. The Avro writer stays until manifest writing is also delegated.Side findings (not blockers, worth separate fixes)
ManifestWriter.add_entrycallswrite_block([entry]), so every entry gets its own deflate stream and sync marker. Re-encoding a 10K-entry manifest with 64 KB blocks (Java's default) made it 5x smaller (3.6 MB to 0.7 MB) and about 1.5x faster to read for both the Cython and the iceberg-rust decoder on the 12-column benchmark; on a 4-column schema the size win holds (6x) but the read speedup is only about 1.1x. Details and numbers in the comment below. Fix: buffer entries inManifestWriterand flush blocks at a size threshold.timestamp_ns/timestamptz_nsmanifests.pyiceberg/utils/schema_conversion.pywrites{"type": "long", "logicalType": "timestamp-nanos", "adjust-to-utc": ...}but_convert_logical_typeonly handlestimestamp-micros, so reading fails withValueError: Unknown logical/physical type combination. Reproduced on 0.12.0 and onmain(7539661). Needs its own iceberg-python issue; independent of this epic, but V3 ns partition tests cannot pass on either path until it is fixed.pyiceberg_core.datafusion. iceberg-rust#3143(2026-09-03) dropped the DataFusion table provider from the binding; a wheel built frommainhas nopyiceberg_core.datafusionmodule. PyIcebergmainstill doesfrom pyiceberg_core.datafusion import IcebergDataFusionTableinpyiceberg/table/__init__.pyforto_datafusion, so it breaks on that release unless PyIceberg adapts first. Needs its own iceberg-python issue; unrelated to manifests, but it gates bumping thepyiceberg-corelower bound in Phase 3.ManifestWriter::write_manifest_filefails withCould not find matching type in UnionSchema { schemas: [Null, Uuid], ... } for Bytes([...])(iceberg-rust#2913, open; reproduced onmain): the literal is serialized as 16 raw bytes and apache-avro 0.21 rejects them underSchema::Uuid. The mapping incrates/iceberg/src/avro/schema.rs(PrimitiveType::Uuid => AvroSchema::Uuid) would emit{"type": "string", "logicalType": "uuid"}; the spec and Java'sTypeToSchema.UUID_SCHEMAusefixed(16)+logicalType: uuid. Same item as "Uuid as fixed[16]" in the deferred list below; iceberg-rust#2916(draft) emitsFixed(16)but without thelogicalTypeattribute. Write side only; the read side is the uuid gap above.fixed(16)+logicalType: uuidinto a string-shapedSchema::Uuidand decodes it as a length-prefixed value, which is the uuid read panic above; 0.22 addsUuidSchema::{String, Bytes, Fixed}and decodes theFixedform as 16 raw bytes. Tracked upstream as iceberg-rust#3063and part of the first reader PR per the deferred list. The uuid read has not been verified against a 0.22 build.Open questions
ManifestWriter,ManifestListWriter) move to iceberg-rust at the same time, so PyIceberg does not need to keep two representations in sync? The previous attempt deliberately kept scope to reads.pyclassper entry? It sidesteps most of the per-object overhead and matches howinspectmetadata tables are consumed.Datum::to_bytes), so it is a second serialization boundary with the same decode dependency asPyDatum.References
https://github.com/apache/iceberg-rust/tree/main/bindings/pythonhttps://github.com/apache/iceberg-python/pull/2004https://github.com/apache/iceberg-rust/discussions/2062