Efficient Column Updates in Apache Iceberg
Efficient Column Updates in Apache Iceberg
Author: Anurag Mantripragada (anuragmantripragada@apache.org)
Authors from Column Families proposal: Péter Váry (pvary@apache.org) Gábor Kaszab (gaborkaszab@apache.org)
(with input from Anton Okolnychyi, Daniel Weeks and Parth Chandra and many community members)
Historically designed for append-heavy workloads and immutable objects, modern data lakes are increasingly serving as the backbone for AI/ML workflows like vector stores and feature stores. Unlike traditional business intelligence data, these workloads are characterized by high dimensionality (tables with thousands of columns), heterogeneous content (mixing structured data with unstructured text, images, and embeddings), and asynchronous data lifecycles.
In this new paradigm, data is rarely static. A single "row" is often composed of base attributes that rarely change, combined with hundreds of derived attributes (e.g., embeddings, classification scores, tokens) that evolve rapidly and independently. This mismatch between the physical storage layout (row-oriented grouping) and the logical update patterns (column-oriented evolution) creates inefficiencies in current table formats such as Apache Iceberg and file formats such as Apache Parquet.
The core limitation in current Apache Iceberg implementation (with file formats like Parquet) is the coupling of columns within a file. While columnar formats allow efficient reads by skipping unnecessary columns, they enforce rigid writes. Current Iceberg implementation provides two ways to update data:
The row-granularity limitation becomes particularly problematic for ML/AI workloads:
In all the above cases, the cost of adding or updating one column is proportional to the size of the entire table, not just the column being changed. This proposal attempts to address the write amplification problem in Iceberg by introducing column-level updates, enabling engines to write the updated columns to separate column files [a][b][c][d][e][f]and efficiently stitch the column files during read time to materialize all the rows of the table.
The proposal is to achieve column-level updates through Iceberg metadata enhancements alone, without requiring modifications to Parquet libraries. The proposal is based on the the in-flight Iceberg Single File Commitsproposal.
Note: A Parquet-native approach was evaluated but discarded to keep that design at table format level (see Discarded Options).
The key principle of this approach is that column updates are handled purely through metadata mutations, leaving underlying base data files untouched to prevent costly write amplification. This is achieved by logically associating new column files with existing base files at the manifest level.
Based on the scope of the operation, the engine could choose either of the following:
We add a new struct field `column_files` to the ContentEntry structure.
Field ID | Name | Type | Required or Optional | Description |
158 | column_files | list<column_files_struct> | optional | Represents a list of column update files associated[g][h] with this entry. For planning, these new column update files must be read along with this entry. If this is null, there are no column updates. This is only valid if ContenEntry.Type is 0: DATA |
161 | format_version[i] | int | required | Format version of this column file |
162 | field_ids | list<int> | required | The field ids, this column file contains. This can overlap with the columns in the base file. For complex types, we replace the entire top level field and include the top level field id here. |
164 | location | string | required | Path to the column update file |
165 | file_format | string | required | String file format name: `avro`, `orc`, or `parquet` |
166 | file_size_in_bytes | long | required | Total size of the column file in bytes. Required by engines like Trino that validate file size at open |
167 | key_metadata | binary | optional | Implementation-specific key metadata for encryption. |
168 | split_offsets[j] | list<169: long> | optional | Split offsets for the column file. Must be sorted ascending. |
Note: Tracking fields are carried on the parent TrackedFile entry via the V4 Tracking struct (see TrackedFile schema()). When a column file is added, the parent entry's latest_column_file_snapshot_id records when the column file became part of the table state.
There is at most one column_files_struct entry per field_id for a given base file. When the same field is updated again, the writer produces a new column file that carries over all previous values and updates the old column_file_struct.
The following examples illustrate typical column UPDATE operations and their corresponding representation.
{ |
{ |
{ |
{ |
This is not allowed. Each field_id must appear in at most one column_files_struct entry. To update field 5 again, the writer must produce a new column file that carries over the values from the previous column file for field 5, replacing the old entry.
When an engine processes a column update statement, the write operation proceeds in the following phases:
The engine identifies all base data files that will be affected by examining the root manifest. The engine produces logical updates by reading the base file and maintaining its position in a tuple (new_value, _pos, file_path), which identifies the new value, its ordinal row position (_pos),and the base file it belongs to (file_path). The engine then co-locates the updates by distributing them by file_path. Ordering the rows before the write phase by file_path and row position is also required.
For each base file, the engine writes a new "column file" containing only the updated column data. The critical decision here is the row alignment strategy (described in detail below).
A column update file MUST adopt a fully-aligned representation, ensuring strict positional alignment with its associated base file. This requirement implies that the column file contains precisely the same record_count as the base file, where every row i in the update file maps directly to row i in the base data. Readers can then execute efficient positional substitution: when a projected field is mapped to a column file, the engine simply retrieves row i from the update file rather than the base.
The rationale for this approach is that by specifying this representation, we avoid forcing all implementations to handle the more complex position based stitching with the base file.
Deleted positions are filled with NULL. To permit this, every leaf column in a column update file is nullable, even when the corresponding table or base column is required (non-null). This strategy keeps min/max bounds clean.
For each field_id represented, a column update file must be be fully-aligned, matching the base_file.record_count precisely by providing a physical value for every row position:
Every write operation is required to include the _pos field within the corresponding update files because it facilitates debugging. Benchmarks show that it compresses well in Parquet so it is not a significant overhead.
The commit process for a column update involves traversing the manifest tree of the preceding snapshot to locate manifest entries where the base file is affected. For every target entry, the commit operation generates a corresponding pair of entries within the new snapshot:
This REPLACED/MODIFIED pair can be co-located within the same manifest file. Alternatively, the writer may retain the REPLACED entry in its current leaf manifest marking the status change via a Manifest Delete Vector (MDV) while writing the MODIFIED entry to the root manifest, a new leaf manifest, or a different existing leaf. Engines determine the optimal layout based on commit-cost trade-offs, and readers reconcile the pair across manifests by joining on the data_file_path. Both implementations ensure consistent commit semantics.
As an alternative to rewriting manifests, engines can use a merge-on-read approach that leverages V4 Manifest Delete Vectors (MDVs). This strategy avoids modifying existing leaf manifests especially for partial updates. An update operation will invalidate the data file entries in existing leaf manifests using MDVs and write new manifests with the updated entries with the associated column update files.
Query planning with inline column metadata follows Iceberg's standard two-phase planning model. No additional phase is required because column file mappings are pre-resolved at write time.
Planning starts by reading the root manifest and resolving manifest entries. Leaf data manifests are identified for processing. Since column update references are embedded directly in data file entries in the manifest, no separate metadata is needed in this phase.
For each leaf manifest, the planner evaluates entries using their content_stats directly because they were updated at write time to reflect merged statistics from both the base file and all column updates. Entries that cannot match the query predicate are pruned. For entries that survive pruning, the column_files list is already present and fully resolved, no cross-manifest join or additional scanning is required.
For each surviving entry, a FileScanTask is created containing a DataFile object. This object encapsulates the base file path and the column_files list directly from the entry. When a column_files entry exists with the field id of a projected field, it reads from the column file, otherwise from the base file. Since each field_id appears in at most one column file, no conflict resolution is needed.
1.5.1 Equality Delete handling
Writers must reconcile equality deletes by materializing them as DVs at the column update's sequence number. This makes the original equality delete obsolete and avoids the read-time interleaving..
The FileScanTask will be enhanced with a columnMapping field to map field IDs to files. A new ColumnStitchingReader will efficiently combine columns in a batch.[k]
interface DataFile extends ContentFile { * It checks the column files first and falls back to the base file's path. .findFirst() .map(ColumnFileInfo::location) .orElse(path()); } |
RewriteDataFiles operation merges all associated column files into the newly rewritten base file. This "collapses" the updates into a single physical file.
For change detection, see Appendix
This strategy involves creating column update files with a row count and row group structure identical to the base file. The primary benefit is highly efficient reads, as a scan task for a base file's row group directly corresponds to the same row group in the column file, both sharing the same number of rows. A potential drawback is that the differing data profiles between the base file and the column files may lead to a suboptimal physical layout for the column update files. Additionally, the column stats need some adjustment with this approach.
Dense representation has two options
Pros:
Cons:
2. Option B: Don't write deleted rows
Column files contain only non-deleted rows (rows that are live after applying DVs). The file has fewer rows than the base file.
Pros:
Cons:
Alignment in non-java engines
b. Sparse Column Update (Dense Representation chosen)
In this strategy, column files only contain rows from the updates column values, they do not contain updates from deleted rows or rows that did not match the update condition. For this to work, the writer needs to materialize the row position (_pos) in the column update file to map the value back to the original row. The drawback is that reader implementation is significantly more complex. It must perform a join on the _pos column to stitch rows and must also handle scan splits that do not align with the column file's row group boundaries.
{
"data_file_path": "base_file_1.parquet",
"column_updates": [
{
"field_id": 12,
"update_file_path": "packed_col_A.parquet",
"row_range": "0-1000"
}
]
},
{
"data_file_path": "base_file_2.parquet",
"column_updates": [
{
"field_id": 12,
"update_file_path": "packed_col_A.parquet",
"row_range": "1001-2000"
}
]
}
Lance implements column-level data evolution through a fragment-based architecture where each Fragment contains multiple DataFiles, with each DataFile storing a distinct subset of columns identified by field IDs. When new columns are added via add_columns() or merge(), Lance writes only the new column data to a separate DataFile within the same Fragment. The DataFile.fields array in the manifest tracks which columns reside in which file.
To maintain alignment when columns are added to Fragments with deletions, the DeletionRestorer inserts placeholder values at deleted row positions, ensuring new DataFiles have the same physical row count as existing ones. This design means write time involves computing new column values and writing a new DataFile with updated Fragment metadata, while read time involves opening multiple DataFile readers and positionally merging their batches.
Apache Paimon implements column-level updates for append tables through its Data Evolution feature, which uses row ID tracking to enable positional stitching of columns stored in separate files. Each DataFileMeta contains a firstRowId field and a writeCols list specifying which columns the file contains—the Snapshot.nextRowId field tracks the global next available row ID across commits.
Statistics are stored per-file in SimpleStats and when multiple files cover the same row range, DataEvolutionFileStoreScan.evolutionStats() builds composite statistics using DataEvolutionRow and DataEvolutionArray structures that map each output field to its source file and position via rowOffsets[] and fieldOffsets[] arrays—this enables pruning predicates to be evaluated against merged stats from multiple files.
At read time, DataEvolutionSplitRead uses RangeHelper.mergeOverlappingRanges() to group files by overlapping firstRowId ranges, then creates a DataEvolutionFileReader that wraps multiple inner readers; DataEvolutionIterator.next() synchronously advances all readers and populates a DataEvolutionRow that redirects field accesses to the appropriate source row. Alignment is guaranteed by validating that all files in a merge group have identical rowCount and firstRowId values. This means write time involves writing only the updated columns to new files with the target firstRowId range (files are small, containing only delta columns), while read time performs lightweight positional stitching without any key-based joining.
Apache Hudi's RFC-80 proposes Column Groups, a mechanism to horizontally partition tables where each column group maintains independent base files and log files that can be written and compacted separately. Column groups are defined at table creation time, with each group containing a subset of columns plus the primary key for joining. File naming includes a _cfName suffix to distinguish groups. At write time, data is sorted by primary key then split by column group, with each group written to its own log files independently—updating one column group doesn't touch others. Statistics are maintained per-column-group file, enabling predicate pruning at the group level.
At read time, a RowReader coordinates multiple GroupReader instances (one per column group), each performing sort-merge between its base and log files; the RowReader then performs a sort-merge join on primary key across groups to reconstruct complete rows. Compaction can operate per-column-group (reducing write amplification) or as "full compaction" that merges all groups. Unlike positional stitching approaches, RFC-80 requires primary-key-based joining and upfront column group definition, but leverages sorted data for efficient merging without explicit row IDs.
Appendix: Discarded Options
The decision to discard the Parquet-native approach came down to these main arguments:
This approach leverages Parquet's native support for vertical partitioning to enable column-level updates without requiring table format changes.
Parquet includes native support for vertical partitioning through the `ColumnChunk.file_path` field:
From parquet.thrift
struct ColumnChunk { |
A column update operation will write column update files that are self-describing: they physically contain only the updated columns but their footer metadata describes the complete row structure including references to unchanged columns in external files. The latest version file serves as both data and metadata. The table format (like Iceberg) simply references the update file path in its normal DataFile entry—no metadata changes are required.
Base File (users.parquet): |
ColumnUpdateWriter writer = ColumnUpdateWriter.builder()[n][o] |
Reading self-describing column update files extends Parquet's standard reader to discover and handle columns stored in external files. The reader examines the footer to identify column locations and transparently stitches data from multiple files.
The read process begins by reading the footer from the latest column file to discover column locations. The footer contains complete metadata for all columns in the table schema. Once the footer is loaded, the reader scans all ColumnChunk.file_path fields across row groups to identify which external files are referenced. For each unique external file path discovered, the reader resolves the relative path and prepares to open the file for reading. This discovery process is transparent—files without column updates have all file_path fields set to null, so they work exactly as before without any external file references.
The statistics in the footer for update files reflect the current state of the
updated columns only. For predicates involving columns stored in external files,
the reader performs targeted footer reads:
Column predicates that are local are instantly evaluated by leveraging statistics from the primary footer.
External column predicates are evaluated by checking the external file that contains the column (using the file_path).
After identifying row groups that survive pruning, the reader builds a column-to-file mapping. For each projected column:
The reader groups columns by source file to minimize I/O operations, then reads from each file and assembles the pages into a unified PageReadStore. Columns with file_path = null are read from the current file being processed, while columns with external file paths are read from their referenced locations. This grouping strategy ensures that the reader opens each external file only once per row group, caching streams for efficient access across multiple columns from the same source.
For optimal performance on cloud storage, the reader can open external files in parallel once all paths are discovered from the primary footer.
Since column files are row-aligned with their source files, the reader performs positional stitching. Row N from each column source corresponds to row N in the logical table. The existing RecordReader assembles complete rows by reading values from each column's PageReader without modification.
Add Multi-File Support to the existing reader class.
public class ParquetFileReader implements Closeable { |
// NEW: Multi-file row group reading |
This approach was discarded due to the additional planning overhead to calculate the column and file mapping and the need to use a stats override file for every column update.
In this approach, column update files are written as new data files to separate manifests, leaving the base manifests untouched. These column files are structured as standard DATA entries. They include a referenced_data_file pointer to their base file and a list of the columns they contain. This design is focused on minimizing write amplification and is consistent with the V4 single-file-commit principle, where base manifests are immutable, and the final column-to-file mapping is determined during planning.
Field ID | Name | Type | Required or Optional | Description |
TBD | field_ids | list<int> | optional | Field ids that are physically present in the file. (updated or new columns) |
Base file: base.parquet (1000 rows, fields=null → all columns) |
When new column files are written in an additive fashion, the stats for the updated columns become stale in the base file. To overcome this, every column-level update will also create a corresponding stats override entry which is a new type of entry that contains the stats overrides for the updated columns.
Field ID | Name | Type | Required or Optional | Description |
134 | content_type | int | required int with meaning: 0: DATA 1: POSITION DELETES 2: EQUALITY DELETES 3: DATA_MANIFEST 4: MANIFEST_DV 5: STATS_OVERRIDES | New STATS_OVERRIDES entry which is only allowed at the root manifests and only applies to a single manifest file |
... | ... | ... | .... | All the other fields of the content entry from V4 |
143 | referenced_file | string | optional | Location of affiliated data manifest if content_type is 5 |
... | ... | ... | ... | All the other fields of the content entry from V4 |
146 (individual fields in content_stats struct will have their own IDs) | content_stats | struct | optional | Only for the updated columns |
... | ... | ... | ... | All the other fields of the content entry from V4 |
A new commit starts by reading the previous snapshot's root manifest. For column updates, a new root manifest is created with a new leaf data manifest containing the added data entries for the column files along with a stats override entry. The entries in the column update leaf manifest will all have references to a single data file they apply to and a list of field ids this column file contains.
Query planning with column updates extends Iceberg's existing two-phase planning to a three-phase model.
Planning starts by reading the root manifest and resolving manifest entries. When a Stats Override (SO) entry is encountered, the planner loads its content_stats and associates them with the target manifest specified in manifest_location. These override stats are held in memory as a map from manifest path to replacement content_stats.
The FileScanTask will be enhanced with a columnMapping field that contains a mapping from the field_id to the file.
A new ColumnStichingReader will stitch the columns in a batch.The FileScanTask will be improved to include a columnMapping field, which will map the field ID to the corresponding file.
Additionally, a new ColumnStitchingReader will be introduced to efficiently combine columns within a single batch.
Change Detection Scenarios with Column Updates
Change detection for column updates follows the same V4 Single File Commits mechanism used for DVs and other manifest mutations. No additional CDC-specific design is needed.
File-level change detection: The general file_changes() algorithm from Single File Commits identifies REPLACED manifest entries, matches them by data_file_path to their EXISTING/ADDED counterparts, and diffs the column_files lists to produce column_additions and column_removals. This works identically regardless of whether column files are dense or sparse.
Row-level change detection: Uses the existing _last_updated_sequence_number metadata column (same mechanism as row lineage). The reader infrastructure already supports two modes:
Writer responsibility during carry-over:
CDC query: SELECT * FROM table WHERE _last_updated_sequence_number > <checkpoint_seq> returns only rows that were actually changed since the checkpoint excluding carried-over rows.
Why this works for both representations:
Column updates use the REPLACED status from the V4 Single File Commits design to enable change detection. When a column update modifies the column_files for a base file, the old manifest is marked REPLACED and a new manifest is written with updated entries containing the new column_files list.
A column update is distinguished from a row-level overwrite by the presence of a REPLACED manifest paired with an ADDED manifest where entries share the same data_file_path. In a regular overwrite, old and new entries reference different physical files.
In this representation, every column file has the same row count as the base file. Row N in the column file corresponds to row N in the base file.
A table has base file D1 with 1000 rows. A column update rewrites field 3 (salary) for all rows via column file CF1, which also has 1000 rows.
Successive updates on different fields:
If a second update adds field 4 via CF2, the delta isolates the change:
Successive updates on the same field:
If field 3 is updated again, the old CF1 is replaced by a new CF1' (carry-over semantics). The delta is:
Interaction with row deletes:
If a DV is also present, deleted positions take precedence:
Only rows at positions {1, 4, 7} need updated values. However, in the full row-aligned representation, the column file still has 1000 rows and filler values occupy the unchanged positions.
Problem for change detection: The CDC reader sees 1000 values in CF1, all positionally aligned with D1. It has no way to distinguish which values are real updates vs filler values for unchanged rows. The reader would either:
Possible mitigation:
In this representation, the column file contains only the rows that were updated, identified by their _pos (ordinal position in the base file).
A table has base file D1 with 1000 rows. A column update rewrites field 3 (salary) for all rows via sparse column file CF1, which contains 1000 rows with _pos = {0, 1, 2, ..., 999}.
For the full update case, the _pos column is redundant (it's a contiguous range covering all rows), but the format is consistent with the partial case.
Successive updates on different fields: Same delta logic as A.1 — only new column files produce change events.
Successive updates on the same field:
Same carry-over semantics as A.1 — old CF1 replaced by CF1', delta = [CF1']. UPDATE_BEFORE reads from old CF1, UPDATE_AFTER from CF1'.
Interaction with row deletes:
Only rows at positions {1, 4, 7} are updated. The sparse column file CF1 contains exactly 3 rows.
Interaction with row deletes:
Assumption 1: We concluded on having “complete” representation for partial updates. This means that we write all the fields of the updated column(s) into the column update file, both the changed and the unchanged values.
Assumption 2: When committing column update into table metadata, we clone the affected manifests while adding column update information to the cloned manifests.
Requirements:
Steps:
Column File Representation
The column file is written with exactly base_file.record_count rows. Row N in the column file corresponds to row N in the base file. For positions that have been deleted in the base file's DV, the writer emits a filler value (NULL by default; alternative strategies discussed separately in Section 1.3.3 of the main document). For unchanged positions, the writer carries over the existing value.
At read time, the reader projects the updated field from the column file in place of the base file. Because both files have identical row counts and identical row ordering, the stitched batch is assembled by reference substitution: the column file's column array replaces the base file's column array for the updated field. The DV is then applied to the stitched batch, discarding the deleted positions (including the filler values)
No. | Concern | Description | Potential Resolution |
1 | null_value_count inflated | Parquet footer counts NULL fillers at deleted positions as real NULLs, causing IS NULL predicates to fail to prune the file and over-reservation of buffers based on inflated null count | Writer subtracts delete_count from the manifest entry's content_stats.null_value_count (delete_count comes from the base file's DV at commit time) |
2 | value_count inflated | Parquet footer counts all rows including filler positions, leading CBO to over-estimate cardinality and pick the wrong join strategy | Writer subtracts delete_count from content_stats.value_count |
3 | NOT NULL field cannot hold NULL fillers | If a field is declared required in the table schema, writing NULL at deleted positions violates Parquet's strict schema enforcement | Column file's Parquet schema declares all updated fields as optional, regardless of the table schema's nullability constraint |
4 | NULL fillers leaking into expression evaluation | Operators like salary * 2 or col IS NULL evaluated on the stitched batch before the DV is applied will see filler NULLs and produce incorrect results | Spec mandates that readers MUST apply the base file's DV to the stitched batch before any expression evaluation or operator consumes the values |
5 | Writer doesn't know trailing delete count to pad | If positions {N-k..N-1} at the tail of a base file are deleted, the writer';s live-row iterator ends short and the column file row count diverges from the base file's row count, causing silent off-by-N positional stitching errors | Broadcast a file_path -> record_count map at write planning time; writer pads NULL fillers from last_live_pos + 1 to record_count -; 1 to match base row count exactly (validated by Delta Lake PoC) |
6 | Reader needs delete_count for stats correction | To apply the null_value_count and value_count corrections above, the reader (or stat-publisher) needs to know how many fillers were emitted | delete_count is the cardinality of the base file's DV, available from the manifest entry's dv_info at planning time |
To achieve efficient vectorized reading and avoid joins, readers will insert null values during the read process for positions that are missing in the sparse column files. This approach allows for the efficient stitching of batches from the base file and the sparse column file based on their positions.
Example:
Base File (salary) (other columns redacted) Output Array: [50000, 75000, 55000, 70000, 72000, 62000, 58000, 80000, 59000,] 0 1 2 3 4 5 6 7 8 ↑ ↑ ↑ from update from update from update |
The column file contains only the rows that are live after applying the base file's DV. A _pos column is required, with one value per row of the column file, identifying that row's ordinal position in the base file. The column file's row count equals base_file.record_count − dv_cardinality.
At read time, the column file contains values for every row that is live after the base file's DV is applied. The reader reads the base file's full batch (including positions deleted by the DV), populates the updated column from the column file using _pos to place each value at its corresponding base position, then applies the DV as a final filter to drop the deleted positions from the output. This requires a scatter operation: for each row position in the base batch, look up whether the column file has a matching _pos and emit the corresponding column file value at that position; positions without a matching _pos correspond to rows that will be filtered out by the DV downstream and can hold any value in the stitched batch. The base file's value for the updated column is never used at read time.
Spark manages in-memory columnar batches via the abstract ColumnVector class, granting the Iceberg reader full control over the path from Parquet decoding to batch population. This architecture enables efficient scatter-style stitching by subclassing ColumnVector and overriding type-specific accessors (e.g., getInt(rowId)). Read operations are redirected to either the base file or column file using a precomputed basePosToCfRow lookup array.
class StitchingVector extends ColumnVector {
ColumnVector cfVec; // CF1 column
int[] basePosToCfRow; // base _pos → CF1 row index, or -1
ColumnVector baseVec; // D1 column (for unchanged rows)
@Override public int getInt(int rowId) {
int cfRow = basePosToCfRow[rowId];
return cfRow >= 0 ? cfVec.getInt(cfRow) : baseVec.getInt(rowId);
}
}
The iceberg-rust implementation relies on concrete arrow-rs array types where value access compiles to direct memory loads. Unlike the Spark ecosystem, there is no equivalent to the polymorphic dispatch found in ColumnVector; because array types are implemented as structs rather than interfaces, value reads cannot be intercepted via subclassing.
Consequently, generating a scattered column requires allocating a new array through ArrayBuilder and emitting values row-by-row. This results in O(rows) complexity due to materialization, per-row copies, and the absence of SIMD acceleration. While arrow-rs supports gather operations (take), it lacks native scatter primitives suitable for this resolution logic.
Additional overhead occurs at row group boundaries. Since the ParquetRecordBatchReader aligns batches to row groups, any sizing divergence between base and column files necessitates a concat-refill operation. A performant resolution would require an upstream API to force batch alignment without additional memory copies.
PyIceberg utilizes native PyArrow array types. Producing a scattered column involves allocating a new array sized to the base batch where column file values are placed at specific _pos coordinates, with remaining slots defaulting to NULL. Although the pc.scatter primitive (introduced in Arrow 23.0.0) executes in C++, the O(rows) cost of allocation and per-position writes remains unavoidable.
The iceberg-go implementation builds upon arrow-go compute kernels and array.RecordReader. Similar to the Rust implementation, value access is performed through concrete array types without polymorphic dispatch, precluding the use of the ColumnVector override pattern.
To resolve scattered columns, the reader must utilize ArrayBuilder to materialize a new array row-by-row. While arrow-go provides Take (gather) and FilterRecordBatch in its selection kernels, it currently lacks a native Scatter primitive.
For partial updates, The sparse column file strategy (Option 2 in Section 1.3.3) stores only the changed rows identified by their ordinal position (_pos) from the base file. This enables partial row updates, a key advantage over the dense/fully-aligned approach where only a small percentage of rows are updated without write amplification for unchanged rows.
In the sparse representation, the presence or absence of a position in the column file carries semantic meaning:
Three scenarios arise when sequential UPDATE operations produce sparse column files for the same base file:
UPDATE tbl SET col_1 = val_1, col_2 = val_2 WHERE id < 1000;
UPDATE tbl SET col_3 = val_3, col_4 = val_4 WHERE id >= 1000;
Column file A contains [_pos, col_1, col_2] for positions 0-999. Column file B contains [_pos, col_3, col_4] for positions 1000-1999. No field_id overlap.
"column_files": [
{"field_ids": [1, 2], "file_path": "col_file_A.parquet", "sequence_number": 3},
{"field_ids": [3, 4], "file_path": "col_file_B.parquet", "sequence_number": 5}
]
UPDATE tbl SET col_1 = val_a, col_2 = val_b WHERE id < 1000;
UPDATE tbl SET col_1 = val_c, col_2 = val_d WHERE id < 500;
The second update targets col_1 and col_2 which already have a column file entry, and the rows overlap (id < 500 is a subset of id < 1000). Per Section 1.2.4, the writer carries over values from the previous column file and produces a merged file. Positions 0-499 get the new values (val_c, val_d), positions 500-999 retain the old values (val_a, val_b).
"column_files": [
{
"field_ids": [1, 2], "file_path": "col_1_2_merged.parquet", "sequence_number": 5
}
]
Carry-over is straightforward because both files contain only col_2. The writer reads the old file, merges positions (newer value wins for conflicts), and writes the merged result.
UPDATE tbl SET col_1 = val_a, col_2 = val_b WHERE id < 1000;
UPDATE tbl SET col_2 = val_c, col_3 = val_d WHERE id >= 1000 AND id < 2000;
The second update overlaps on col_2 but targets entirely different rows. Per the no-overlap invariant, the writer must carry over col_2 from column file A into a new file and remove col_2 from the old entry. However:
If the writer attempts to merge col_2 into column file B (which also contains col_3), the merged file would have positions 0–1999 for col_2 but only positions 1000–1999 for col_3. Parquet requires uniform row counts across columns within a file positions 0–999 would need values for col_3. These cannot be NULL because the reader cannot distinguish "no update" from "intentionally set to NULL" when the position is present in the file. Filling with any sentinel value breaks the sparse representation's semantic contract.
To solve this, when overlap is detected, extract the overlapping column into a separate single-column merged file containing the union of positions from both old and new updates. Non-overlapping fields remain in their original multi-column files:
After first update:
{"column_files":[
{
"field_ids":[1,2],"file_path":"col_file_A.parquet","sequence_number":3}
}
]
}
After second update:
"column_files": [
{"field_ids": [1], "file_path": "col_file_A.parquet", "sequence_number": 3},
{"field_ids": [2], "file_path": "col_2_merged.parquet", "sequence_number": 5},
{"field_ids": [3], "file_path": "col_file_B.parquet", "sequence_number": 5}
]
What happened:
Read data files with column files
Currently, the read architecture is capable of reading individual files through the File Format API. With column (update) files, a logical data file may be represented as multiple physical file segments, where each field ID is stored in exactly one vertical split. Reading such a representation requires several additional steps beyond reading a single file:
Reusability: An important aspect of the implementation is to avoid implementing the same across format models, query engines or different flavours of the readers. The implementation should be reusable as much as possible.
In V4 we propose to introduce column update files not just for data files but for manifest files too. However, This section focuses on reading data files with column files and not on manifests with column files..
In the Java implementation, the plan is to expose column files through the DataFile API. See doc and PR. This helps when passing through file information to read or query plan APIs because DataFile already flows through these APIs.
interface DataFile extends ContentFile<DataFile> {
default List<ColumnFile> columnFiles() { return List.of(); }
}
class TrackedFileAdapters {
private static class TrackedDataFile extends ... {
@Override
public List<ColumnFile> columnFiles() { return file().columnFiles(); }
}
}
(Diagram generated by AI)
FormatModelRegistry: From the user’s perspective this is the central entity for reading data files. After providing the FileFormat, the in-memory row representation (e.g. InternalRow.class, Record.class, etc.) and the particular InputFile to be read, the registry returns a ReadBuilder if there is one registered for the given format and row type.
public final class FormatModelRegistry {
public static <D, S> ReadBuilder<D, S> readBuilder(
FileFormat format, Class<? Extends D> type, InputFile inputFile) { . . . }
}
FormatModel: This is the abstraction that is responsible for converting between the physical representation of a file to the internal representation of rows. Once a format model is registered to the registry, users can use it via calling:
Readbuilder<Record, ?> b =
FormatModelRegistry.readBuilder(FileFormat.PARQUET, Record.class, someInputFile);
Note, the example used Parquet as the physical format of the data file and Record as the internal representation.
When creating a format model these inputs are required:
An essential part of the read path is when and how we create an InputFile from a ContentFile. InputFile is the input of the FormatModelRegistry (and in turn to the ReadBuilders). If a ContentFile has a non null encryption key_metadata then the derived InputFile is in fact an EncryptedInputFile and contains this information for decryption.
The process of turning ContentFile into InputFile goes through EncryptingFileIO and an EncryptionManager:
public class EncryptingFileIO {
public Map<String, InputFile> bulkDecrypt(Iterable<? extends ContentFile<?>>);
// bulkDecrypt() calls this and then passes the results to EncryptionManager in bulk
private SimpleEncryptedInputFile wrap(ContentFile<?>);
}
public interface EncryptionManager {
public static InputFile decrypt(EncryptedInputFile);
// There is one API for bulk decryption too:
public static Iterable<InputFile> decrypt(Iterable<EncryptedInputFile>);
}
Here EncryptedInputFile is a wrapper on top of InputFile adding the encryption key metadata on top.
Spark and Flink use the bulk interface on the EncryptionManager API above. The reading process has the below steps:
When reading a ContentFile we follow these steps:
The steps described above are important to understand the architecture for reading a file in the context of column files. For instance a natural abstraction would be to pass a ContentFile to a Composite reader that can handle reading the base file and the column files together including the creation of different readers, splitting the rows together, handling projections, etc. However such a design would result in a one-by-one creation of InputFile from ContentFile that would break the contract of the bulk creation.
(Diagram generated by AI)
FormatModelRegistry[s][t]: This class is intact. As described above, this serves as the entry point to the File Format API for reading a single physical data file.
ContentFileReadBuilder: An additional abstraction layer between engine readers and the File Format API. While the engine readers get ScanTasks and in turn ContentFiles as inputs, the File Format API gets an InputFile. Since ContentFile/DataFile contains info about column files, we either do the handling of them on each engine reader or as suggested here, we can introduce another layer below the engine readers to accept ContentFile as an input and do the column file handling. This layer can be reused by any engine reader.
Inputs for this layer:
Responsibilities of this layer:
(Diagram generated by AI)
ColumnFile is not a ContentFile:
class TrackedFileAdapters {
static DataFile asDataFile(TrackedFile base, ColumnFile file, Map<Integer, PartitionSpec> specsById);
}
Behaviour of the ContentFileReadBuilder API:
InputFileProvider: This works as a function that gets a String parameter for location and returns an InputFile. In practice when e.g. Spark’s BaseReader.inputFiles() constructs a mapping of locations to InputFiles, it can be used as an InputFileProvider.
Additionally, we can have an implementation of this that is reusable across engine readers in a way where the engine readers provide their ScanTasks as an input and InputFileProvider performs gathering the referenced files, executing bulk decryption, and constructs the location -> InputFile mapping. This way we can avoid InputFile creation (including decryption through EncryptionManager) in ContentFileReadBuilder one-by-one for the input ContentFiles and do them in bulk.
RowStitcher: Each format model has their own implementation on how to stitch vertically split rows together, to act as if it were a single row: InternalRowStitcher, RecordStitcher, etc. This is used by RowAlignedStitchingIterable to stitch the result rows of the vertical split readers together.
FormatModel: Potentially, we can keep the RowStitcher implementations on the FormatModel level. When creating a format model currently we receive the reader function and the writer function that is specific to the model. Similarly, we can receive a model specific stitcher.
The above would introduce some level of redundancy: A FormatModel is identified by file format + internal row representation. However, the stitcher is the same across file formats and only depends on the internal row representation. See (simplified the params to fit):
SparkFormatModels:
AvroFormatModel.create(InternalRow.class, StructType.class, SparkAvroWriter, SparkAvroReader, InternalRowStitcher);
ParquetFormatModel.create(InternalRow.class, StructType.class, SparkParquetWriter, SparkParquetReader, InternalRowStitcher);
OrcFormatModel.create(InternalRow.class, StructType.class, SparkOrcWriter, SparkOrcReader, InternalRowStitcher);
Alternatively, we can introduce a StitcherRegistry that is independent of the file format. Might[z][aa] be an overkill, though.
Alternatively, we can pass a stitcher to ContentFileReadBuilder as a parameter. The users know the internal format already, should also know the stitcher implementation too. Might be redundant information together with the internal format parameter.
RowAlignedStitchingIterable[ab]: Contains nested iterators, one for each vertical split participating in the read. The purpose of this compound iterator is to keep the underlying iterators in-line assuming positional alignment, and to apply the stitcher on top of the result rows.
Split reads need some extra care: split(start, length) makes sense to one reader only (probably to the base file’s reader ATM). We have to make the other iterables aligned with the one that jumps to the beginning of the split:
Later, this can be more advanced: Each column file has a split_offset field. Technically we can pick whichever we want to be the one that jumps to the beginning of the split and iterate the rest of the readers. This is out of scope ATM.
TODO: vectorized reads
Meeting Notes
V4 Spec Clarifications
Column File Read Path Architecture
V4 Spec
Column Files Reviews
Action Items
V4 Writers: Stats Schema Handling
V4 Writers: Commit Operation Intent
V4 Manifest Format
The transition from v3 to v4 requires handling legacy manifests without a full metadata rewrite, which is prohibitively expensive for large tables.
Action Items
Data file stats
Column Updates Metadata representation (dev list thread)
Reviews
Column File representation (dev list thread)
Decision:
Column Updates Metadata representation (dev list thread)
Updates on partition tuples
V4 Metadata: Base File Definition
V4 Implementation Path
Column Update Details
[a]the separated new column parquet file schema will include those updated columns only or the schema will be the same as the original data file schema (will nulls in all untouched columns ?)
[b]This line needs correction. In the column update design descussions, we have decided to copy over the unchanged rows into the column files. I updated that.
[c]The column files will only contain the schema of the changed columns, readers must read those specific columns from the column files ignoring the values in the base file.
[d]if the additional parquet files are used in a READ before being merged into the original data file (in Compaction, right?) you can expect read performance degradation no? one of the goals was "Preserve read efficiency" but reading more parquet files means more reading more parquet footers, decode and decompress activities. based on the question i asked before regarding the schema of that new column files you might want to increase the raw group sizes of those new column files so that you will fetch those column values quicker ... with less parquet metadata I/O ...
[e]There is a read penalty but that is limited to queries reading from both base file and column files. For projected columns on the updates, it's negligible, but this is a good point to clarify in the goals.
[f]For row-group sizing, there is a tradeoff efficient row group sizing vs alignment of those rows at read time. See Column File Representation tab for a discussion of this tradeoff. We concluded that, we will leave that to the implementation.
[g]Does the order of the column files in the list represent the update order? Please clarify.
I am thinking about this scenario:
Base table has column A, B and C.
Query 1 updates B and C, so we have:
data_file (A, B, C) -> [update_file_0 (B, C)]
Now Query 2 further updates C, then we have:
data_file(A, B, C) -> [update_file_0(B, C), update_file_1(C)]
It's important that for column C, the latest snapshot should come from update_file_1 - i.e., later entries in the list override earlier ones. Is that the intended contract here?
[h]This is described in 1.2, we will always allow only one column file per filed id and this metatadata is written in the column_files metadata.
[i]I don't think that we need this at the column file granularity.
[j]Do we need this? I don't think we will be planning splits based on this so I would probably omit it.
[k]The plan is to extend DataFile and/or ColumnFile with a columnFiles() method. Since FileScanTask returns a ContentFile, this gives us access to column file information in planning.
[l]why without a where ? what about update tbl set ... where ... i see that in open Question 5
[m]I read the proposal and i am not sure i understand if this proposal addresses adding/removing columns from a table as well ? AKA Add/Remove column for schema evolution ?
[n]Is this parquet code or iceberg code?
[o]This approach will be entirely in Parquet format, so we would need to discuss it with the Parquet community if we choose this method.
[p]@anuragmantri93@gmail.com
I learned recently, that the current snapshot's sequence number id decided on commit time and it's not available when we write the update file.
Idea to mitigate this: for unchanged rows we can carry over _last_updated_sequence_number, for newly changed rows we could write null for this and during read time we can use the sequence number from the update file metadata to fill these nulls.
[q]As per today's discussion about keeping this format-agnostic, and the dev-list thread
(https://lists.apache.org/thread/lqo71bs2hvzj38f4ljrkz77ncq4boltl), I'd write something like "may use a different internal layout, so reading by split offset alone is not always enough" keeps it true for any format.
[r]this is just an example what I mean here by internal layout. What if I write "different internal layout (e.g. row group misalignment for Parquet)" ?
[s]Could FormatModelRegistry return ContentFileReadBuilder instead?
[t]This is a design choice we went back and forth a lot. On the last sync we seem tp conclude on keep FormatModel and the FormatModelRegistry intact, covering single physical files, while an extra abstraction layer on top can handle logical files with column files involved.
I'm not saying that that's now written into stone, but this is the direction we lean towards now.
[u]Worth stating what happens to the base file's original values for a field that a column file now owns. The base keeping its stale
copy on disk, the reader never projecting it because the field is assigned to the owning column file, and compaction dropping it later.
[v]I'm not sure this is relevant for the read design here. This is more related to the general design, maybe already covered in the first section of this document
[w]Could we push down the relevant part of the filter, and return the `pos` column from the readers and use RowAlignedStitchingIterable to align again when skipped something?
[x]I agree here. I don't think every filter has to run on the stitched row. A predicate that touches only fields owned by one split can be pushed to that split's reader and drive the alignment, since the loop already advances the other readers to the driver's positions. Only predicates spanning more than one split need to run after
stitching.
[y]Yes, you're right. We can't ignore filter pushdown just to make implementation simpler. I'll rewrite this section
[z]As per today's discussion about keeping the stitching above the file format, I'd go with the StitcherRegistry here rather than RowStitcher from FormatModel, and I don't think it's overkill. The stitcher depends only on the in-memory representation ( Record, InternalRow, or a ColumnarBatch)not on the physical format, so registering per representation type avoids duplicating the same stitcher across every FormatModel. It also gives makes it easy to implement vectorized path as another registered stitcher, which is what I'm building to build for Spark and Arrow.
[aa]Alternatively we can make users pass a stitcher function. See the extra option alternative option I added below.
[ab]What about vectorized reads?
[ac]Do we need this for all readers? We only need _pos from the reader that was given the split to find the first row's position, isnt it?
[ad]Because of filter pushdown, other readers might skip rows or batches too. We need a way to see where they are, hence I think we need _pos for all the readers.
[ae]I believe the plan is to bump data sequence number on the base file when adding a new column file. We can use this field for _last_updated_sequence_number, and also this way we can eliminate the previous eq-deletes applied to this base file.
As far as I understand, we would leave the file sequence number intact.
[af]That is my understanding. We can't eliminate the prev equality deletes - they need to be converted into a DV.
[ag]Yes, that's what I meant 🙂 but by bumping seq num, we can achieve not to apply them after rewriting to DVs
[ah]That sounds good. we don't need to discuss it then.
[ai]Might come handy for troubleshooting for writer issues. According to my experiments, using Parquet delta encoding adding a _pos field to the file is almost for free in terms of storage usage.
[aj]That is true, Gabor. But I feel it would be nice to keep it outside the spec and make this an optional implementation recommendation
[ak]See the conversation on dev@ list, we went a couple of iterations on this. Concluded on removing the _pos field from the column file, writers are free to add it, but we won't add this to the spec, not the Java implementation.
[al]I saw the discussion. If that is final, we don't need to discuss it.