Data Evolution
Overview
Paimon supports schema evolution, allowing you to add, modify, or delete column schema. Data Evolution mode extends this for append tables by allowing partial column updates without rewriting entire data files. Updated column data is written to new files and merged with the original data during reads.
Data Evolution mode offers the following advantages:
- Efficient partial-column updates: update a subset of columns and avoid the I/O cost of rewriting untouched columns.
- Reduced file rewrites: append new column data to dedicated files when backfilling or evolving data.
- Delete support: record row deletions with deletion vectors without rewriting existing column files.
- Optimized reads: merge original and updated column files at read time.
To enable Data Evolution, create an append table with both
row-tracking.enabled and data-evolution.enabled set to true.
- Spark SQL
- Flink SQL
- Python API
CREATE TABLE target_table (id INT, b INT, c INT) TBLPROPERTIES (
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true'
);
INSERT INTO target_table VALUES (1, 1, 1), (2, 2, 2);
CREATE TABLE target_table (id INT, b INT, c INT) WITH (
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true'
);
INSERT INTO target_table VALUES (1, 1, 1), (2, 2, 2);
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
catalog_options = {"warehouse": "/path/to/warehouse"}
catalog = CatalogFactory.create(catalog_options)
catalog.create_database("default", True)
pa_schema = pa.schema([
("id", pa.int32()),
("b", pa.int32()),
("c", pa.int32()),
])
schema = Schema.from_pyarrow_schema(
pa_schema,
options={
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
},
)
catalog.create_table("default.target_table", schema, False)
table = catalog.get_table("default.target_table")
builder = table.new_batch_write_builder()
write = builder.new_write()
commit = builder.new_commit()
write.write_arrow(pa.Table.from_pydict(
{"id": [1, 2], "b": [1, 2], "c": [1, 2]},
schema=pa_schema,
))
commit.commit(write.prepare_commit())
write.close()
commit.close()
Partial Updates
You can update selected columns with Spark SQL UPDATE or MERGE INTO, the
Flink data_evolution_merge_into procedure, or the PyPaimon table update API.
Only the updated column files are written; untouched columns remain in their
original files.
- Spark SQL
- Flink SQL
- Python API
UPDATE target_table SET b = b + 10 WHERE id = 1;
CREATE TABLE source_table (id INT, b INT);
INSERT INTO source_table VALUES (1, 11), (2, 22);
MERGE INTO target_table AS t
USING source_table AS s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.b = s.b;
SELECT * FROM target_table;
+----+----+----+
| id | b | c |
+----+----+----+
| 1 | 11 | 1 |
| 2 | 22 | 2 |
+----+----+----+
Flink does not currently support MERGE INTO syntax. Use the
data_evolution_merge_into procedure instead:
CREATE TABLE source_table (id INT, b INT);
INSERT INTO source_table VALUES (1, 11), (2, 22);
CALL sys.data_evolution_merge_into(
'default.target_table',
'',
'',
'source_table',
'source_table.id=target_table.id',
'b=source_table.b',
2
);
SELECT * FROM target_table;
+----+----+----+
| id | b | c |
+----+----+----+
| 1 | 11 | 1 |
| 2 | 22 | 2 |
+----+----+----+
PyPaimon exposes an UPDATE-like table update API. It accepts a Predicate
for the WHERE condition and literal assignments for the SET clause.
table = catalog.get_table("default.target_table")
builder = table.new_batch_write_builder()
table_update = builder.new_update()
predicate = table_update.new_predicate_builder().is_in("id", [1, 2])
messages = table_update.update_by_predicate(predicate, {"b": 100})
commit = builder.new_commit()
commit.commit(messages)
commit.close()
Notes:
- Spark SQL supports standalone
UPDATEstatements for Data Evolution tables. - Concurrent Spark SQL
UPDATEstatements that update the same data file and columns may be retried automatically. Configure retry attempts withspark.paimon.write.data-evolution.update-conflict-retry.max-attemptsand retry wait time withspark.paimon.write.data-evolution.update-conflict-retry.wait-ms. - If concurrent compaction changes row-ID file boundaries after Spark SQL
MERGE INTOstages regular partial-column files, Spark rebases those staged files onto the latest boundaries before committing instead of rerunning the MERGE source and join. Spark performs this rewrite with distributed DataFrame processing, so the PyPaimon-onlydata-evolution.row-id-conflict-rewrite.max-sizelimit does not apply. Recovery does not apply when deletion vectors are enabled or when existing-row BLOB or VECTOR files are staged, and it does not hide logical concurrent-update conflicts. - In Spark SQL,
MERGE INTOsupportsWHEN NOT MATCHED BY SOURCEfor delete actions on Data Evolution tables. - The Flink
data_evolution_merge_intoprocedure currently supports updating or inserting columns, but not inserting new rows.
Deletes
Data Evolution tables can use deletion vectors to record deleted rows without rewriting existing column files. To write deletes, enable deletion vectors together with row tracking and Data Evolution:
Deleting rows with deletion vectors does not physically remove the original
data immediately. Data Evolution compaction preserves row IDs and logical
deletions, so it does not materialize deleted rows. The legacy
data-evolution.compaction.rewrite-row-ids option is no longer supported. Use
the materialize_deletion_vectors procedure to apply deletion vectors to the
latest table state and replace the affected files. Historical snapshots and
tags may still reference the replaced files; reclaiming their storage requires
those references to expire and snapshot expiration to remove the files.
Both Flink and Spark expose the same procedure:
CALL sys.materialize_deletion_vectors(`table` => 'default.target_table');
The procedure can limit the rewrite to selected partitions. partitions and
where cannot be used together:
CALL sys.materialize_deletion_vectors(
`table` => 'default.target_table',
partitions => 'dt=2026-08-12');
CALL sys.materialize_deletion_vectors(
`table` => 'default.target_table',
`where` => 'dt >= 20260801');
Materialization rewrites the affected data, assigns new row IDs to surviving
rows, removes the applied deletion vectors, and drops affected global indexes.
Consumers which persist _ROW_ID values must therefore coordinate with this
operation. Concurrent changes to affected row-ID ranges cause the procedure to
fail instead of committing stale results. Materialization of vector-store files
is not currently supported.
Spark processes bounded batches until all matching deletion vectors have been materialized. To keep a Flink job bounded, one Flink procedure invocation processes one batch with a soft target of 100,000 deletion vectors. An overlapping row-ID component is never split and can exceed the target. Invoke the Flink procedure repeatedly until an invocation makes no changes.
CREATE TABLE target_table (id INT, b INT, c INT) TBLPROPERTIES (
'row-tracking.enabled' = 'true',
'data-evolution.enabled' = 'true',
'deletion-vectors.enabled' = 'true'
);
Spark SQL supports DELETE FROM for Data Evolution tables:
DELETE FROM target_table WHERE id = 1;
Spark SQL also supports delete actions in MERGE INTO:
CREATE TABLE source_table (id INT, op STRING);
INSERT INTO source_table VALUES (2, 'delete'), (3, 'update');
MERGE INTO target_table AS t
USING source_table AS s
ON t.id = s.id
WHEN MATCHED AND s.op = 'delete' THEN DELETE
WHEN MATCHED AND s.op = 'update' THEN UPDATE SET t.b = t.b + 10
WHEN NOT MATCHED BY SOURCE AND t.id > 10 THEN DELETE;
The WHEN NOT MATCHED BY SOURCE clause requires Spark 3.4 or later.
With Flink 1.17 or later, SQL supports DELETE FROM for Data Evolution tables
in batch mode. For row-level predicates, matching rows are recorded in deletion
vectors, so data files are not rewritten:
DELETE FROM target_table WHERE id = 1;
Flink users can also submit the
delete action.
Self Updates
Self updates transform existing column values in place. In Flink SQL, create a
temporary source view from the $row_tracking system table and join by
_ROW_ID. In PyPaimon, use the shard scan + rewrite workflow to read existing
values and write the derived columns back.
- Flink SQL
- Python API
CREATE TEMPORARY VIEW source_view AS
SELECT _ROW_ID, b + c AS b
FROM default.target_table$row_tracking;
CALL sys.data_evolution_merge_into(
'default.target_table',
'TempT',
'',
'source_view',
'TempT._ROW_ID=source_view._ROW_ID',
'b=source_view.b',
2
);
import pyarrow as pa
table = catalog.get_table("default.target_table")
builder = table.new_batch_write_builder()
table_update = builder.new_update()
table_update.with_read_projection(["b", "c"])
table_update.with_update_type(["b"])
updater = table_update.new_shard_updator(0, 1)
reader = updater.arrow_reader()
for batch in iter(reader.read_next_batch, None):
b = batch.column("b").to_pylist()
c = batch.column("c").to_pylist()
updater.update_by_arrow_batch(pa.RecordBatch.from_pydict(
{"b": [bi + ci for bi, ci in zip(b, c)]},
schema=pa.schema([("b", pa.int32())]),
))
messages = updater.prepare_commit()
commit = builder.new_commit()
commit.commit(messages)
commit.close()
Self-update notes:
- The source and target table name cannot be the same in the Flink procedure. Create a temporary view as the source.
- Use
view._ROW_ID = source._ROW_IDto identify the self-merge pattern in Flink. _ROW_IDis only available via the$row_trackingsystem table in SQL.- Self-merge only supports
WHEN MATCHED THEN UPDATEsemantics.
File Group Spec
Through the row-id metadata, files are organized into file groups.
When writing, the Data Evolution update path writes only the specified updated columns to new files. The original data files remain unchanged.
When reading, Paimon reads both the original data files and the new files
containing updated column data, then merges files with the same first row id
to present a unified view of the table.
After writing, files in target_table are organized as below:

When reading, files with the same first row id are merged:

The advantages of this mode are:
- Avoid rewriting the whole file when updating partial columns, reducing I/O cost.
- Keep read performance efficient through optimized merge processing.
- Use disk space more efficiently because only updated columns are written to new files.