Repository navigation
Description
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)
- The binding exists and has shipped in every
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]- Getter-only wrapper classes for
ManifestFile,ManifestEntry,DataFile,FieldSummary, andPrimitiveLiteral.
- 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
contentdefaulted toDATA, 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_byteson data files;first_row_idon 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'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).- 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 = 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:- iceberg-rust parse (~36 us / entry). About 82% is inside
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. - Python-side conversion (~10 us / entry). Rebuilding each entry as a PyIceberg
DataFile/ManifestEntryviafrom_argscosts as much as the entire Cython path (~11 us / entry). PyO3 getter overhead itself is only ~1 us / entry.
- iceberg-rust parse (~36 us / entry). About 82% is inside
- Decimal partition values.
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. - Reader depends on the manifest's embedded
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.- 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_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).- Bounds are decoded to
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.- Sequence-number / snapshot-id inheritance (
inherit_data) ispub(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.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_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,
AvroFilevspyiceberg_core.manifest, end to end including conversion to PyIceberg objects. Results and script are in the comment below. - Build the wheel with
opt-level = 3instead 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+ 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. - Move filtering into Rust:
read_manifest_entries(bs, discard_deleted=True)soDELETEDentries never cross the boundary. - Move inheritance into Rust: expose a public
inheritstep (or aread_manifest_entries(bs, manifest_file)variant) sosnapshot_id,sequence_number,file_sequence_number, andspec_idare already populated. - Stop building one Python object per entry. Return Arrow (a
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. - Gate: no regression versus Cython at 1K, 10K, 50K, and 200K entries.
Phase 2: close the binding gaps (iceberg-rust)
- Return
NonefromManifestFile.partitionswhen summaries are absent (reopen#2886). - Replace
unwrap()in both read functions with properPyErrconversion (reusebindings/python/src/error.rs). Robustness, not ergonomics: one bad manifest is unrecoverable from Python today. - Add a
format_versionargument (or auto-detect) toread_manifest_listand exposefirst_row_id. - Expose
first_row_id,referenced_data_file,content_offset,content_size_in_bytes, andspec_idonDataFile. - 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 existingfrom_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_nspartition values: already fixed onmain(#3091); re-run the round-trip audit against the release that carries it. - Match partition fields by
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. - Accept
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. - Decide whether bounds for field ids absent from the embedded
schemaare 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, 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. - Decide whether to wait for the "derive schema/spec from table metadata" change in iceberg-rust (
#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)
- Revive the consumer change against current
main:read_manifest_listandManifestFile.fetch_manifest_entrycall the binding whenpyiceberg_coreis importable, and fall back toAvroFileotherwise. Keepdiscard_deletedand theManifestFilecache behaviour. - Construct PyIceberg objects via
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. - Add a test that reads a PyIceberg-written manifest with
equality_idsasarray<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. 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. - Gate on the benchmark from Phase 1: no regression versus Cython on large manifests.
- Bump the
pyiceberg-corelower bound to the release that contains Phase 1 and 2.
Phase 4: cleanup (iceberg-python)
- Make
pyiceberg-corea required dependency (or keep the fallback and remove Cython only after one release of soak time). - Wheel coverage before it can be required:
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. - 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.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). - Delete
pyiceberg/avro/decoder_fast.pyx, thecythonizestep insetup.py, and the per-platform wheel matrix in the release workflow. - Remove the now-unused Avro reader code paths (
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)
- PyIceberg writes one Avro block per manifest entry.
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. - PyIceberg cannot read its own
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. - 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 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. - iceberg-rust cannot write uuid partition values.
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. - apache-avro 0.22 upgrade in iceberg-rust. 0.21 parses
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
- 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
pyclassper entry? It sidesteps most of the per-object overhead and matches howinspectmetadata 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 asPyDatum.
References
- iceberg-rust binding:
https://github.com/apache/iceberg-rust/tree/main/bindings/python - Previous PyIceberg attempt:
https://github.com/apache/iceberg-python/pull/2004 - Decimal literal discussion:
https://github.com/apache/iceberg-rust/discussions/2062
Activity
kevinjqliu commentedon Sep 4, 2026
Benchmark: Cython Avro reader vs pyiceberg-core manifest reader
Setup: macOS on Apple Silicon, Python 3.13.12,
pyiceberg0.11.1 (Cythondecoder_fast),pyiceberg-core0.10.1 built from iceberg-rustmain(commit6fa8e0383),apache-avro0.21.0. Manifests were written with PyIceberg's ownwrite_manifest(V2, deflate, 12-column schema, identity partition, full column stats: sizes, value/null counts, lower and upper bounds on every column). Both paths read the same files in the same process. Numbers are best-of-N wall time, single thread. Script at the bottom.Paths measured:
cython:ManifestFile.fetch_manifest_entry(io), today's code path.rust_parse:pyiceberg_core.manifest.read_manifest_entries(bs)only.rust_convert:rust_parse+.entries()+ rebuilding PyIcebergManifestEntry/DataFileviafrom_args+ inheritance. This is what the earlier PyIceberg PR did and is the only apples-to-apples number.1. End to end, as shipped (
opt-level = "z")Manifest lists:
2. Same, rebuilt with
opt-level = 3The published wheel is built with
opt-level = "z"(size). Changing only that flag:opt-level = "z"costs about 1.6x to 1.8x on this workload.3. Where the time goes inside iceberg-rust (no Python), 10,000 entries,
opt-level = 3Manifest::parse_avrosplit into its stages, measured in-crate:apache_avro::Valuetree (writer schema only)Reader::with_schema(reader_schema)(addsValue::resolve)Valueto_serde::ManifestEntryV2(serdefrom_value)ManifestEntryV2toManifestEntry(Datum::try_from_byteson bounds)Manifest::parse_avrototalFor comparison, the whole Cython path including building PyIceberg objects is about 11 us / entry.
Observations
apache-avrobefore any iceberg-rust code runs. Decoding into the genericValuetree alone (12.9 us / entry) is already slower than the full Cython path.Reader::with_schemamore than doubles decode cost.apache-avrodecodes with the writer schema and then, if the reader schema is not==the writer schema, walks the whole tree again withValue::resolve. iceberg-rust's generated reader schema never equals a PyIceberg (or Java) writer schema, so resolution always runs. The diff for this file: the reader adds the V3 fields (first_row_id,referenced_data_file,content_offset,content_size_in_bytes), the reader hasequality_ids: array<int>where the writer hasarray<long>, and the partition field is["null","int"]in the reader but requiredintin the writer.rust_convertminusrust_parse) costs about 10 us / entry atopt-level = 3, as much as the entire Cython path. A faster Rust parser alone cannot reach parity while every entry is still rebuilt as aDataFilein Python.Conclusions
opt-level = 3.apache_avro::Value+resolve+ serde for manifest entries. Either decode directly from the writer schema intoManifestEntry, or at minimum skipwith_schemaresolution when the writer schema is compatible and apply projection defaults in the serde layer. This is the 30 us / entry bucket.RecordBatchin the manifest-entry schema) or construct theDataFileobjects directly from Rust in one pass. This is the 10 us / entry bucket.opt-level = 3. Worth re-checking the binary-size trade-off that motivated"z".Benchmark script (run inside
bindings/pythonaftermake install:uv run --no-sync python bench_manifest.py 1000 10000 50000 200000)kevinjqliu commentedon Sep 4, 2026
Side finding: PyIceberg writes one Avro block per manifest entry
While generating benchmark files it turned out that every manifest written by PyIceberg has exactly one record per Avro block.
ManifestWriter.add_entrycallsAvroOutputFile.write_block([entry])for each entry (pyiceberg/manifest.py), andwrite_blockencodes, compresses, and writes a sync marker for the list it is given. So a 10,000-entry manifest is 10,000 blocks, each with its own deflate stream and 16-byte sync marker. Java'sDataFileWriterflushes blocks at 64 KB. The manifest-list writer is not affected:ManifestListWriter.add_manifestspasses the whole list to a singlewrite_blockcall.This hurts both size and read speed, for every reader. Same 10,000-entry manifest, re-encoded with fastavro at a 64 KB sync interval and otherwise identical (same writer schema and metadata):
Valuedecode, deflateValuedecode, uncompressedDeflate on one record at a time barely compresses (3.6 MB vs 5.0 MB raw) because each stream starts cold, and every reader pays per-block setup and inflate overhead per entry. With 64 KB blocks the same data is 5x smaller and about 1.5x faster to read for both decoders.
Suggested fix in PyIceberg, independent of this issue: buffer entries in
ManifestWriterand flush a block when the encoded size reaches a threshold (64 KB to match Java) or on close, instead of onewrite_blockperadd_entry. Readers are unaffected since the container format does not change. This also makes the "Rust vs Cython" comparison more representative of Java-written tables, which is why the benchmark comment above includes both block layouts.Generated by Claude Code
kevinjqliu commentedon Sep 5, 2026
Deferred from the first iceberg-rust PR (reader parity only)
The first PR upgrades
apache-avroto 0.22 and replaces theValue-based manifest reader with the byte-level serde reader, keeping behaviour identical to the old reader. These improvements were built and measured during that work but are held back so the PR stays a pure replacement:field-idbefore name. Java sanitizes Avro field names that are not valid identifiers and records the original in afield-idattribute; the old reader (and the new one, for parity) matches by name and silently drops such values. The writer schema is available from the reader, so the mapping is oneHashMapper file.equality_idswritten asarray<long>. PyIceberg still writeslong(checked onmain, 2026-09-07); the spec and other writers useint. The byte-level deserializer does not narrow integers, so this needs a small lenient element deserializer.format-version.ManifestList::parse_avrocurrently reads1and3and falls back to V2 for anything else, including unrecognized values.DataFilepath still produces a struct sized to the record. Unifying them changes one round-trip test expectation.fixed[16]. On released iceberg-rust (apache-avro 0.21) uuid partition values cannot be written at all (#2913); with the 0.22 upgrade in this PR they are written as length-prefixed bytes (UuidSchema::Bytes, what 0.21 did forfixed+uuid); the spec asks forfixed[16]. Format-affecting, so it needs its own change and compatibility check.DEPENDENCIES.rust.tsv. The lists are stale repo-wide since the 0.10.0 release; regenerate in a dedicated change with the CI-matching cargo-deny 0.19.9.Generated by Claude Code