Learn about the core configurations of DLF-managed Paimon primary key tables, including bucketing, merge engine, changelog producer, and DLF storage optimization modes, to help you choose the right settings for your workload.
Quick reference
Category | Scenario | Recommended configuration |
Bucketing |
| Postponed bucket (default) |
Requires immediate data visibility after commit in streaming jobs. |
| |
Primary key does not fully contain the partition key and table size < 100 GB. | Dynamic bucket ( | |
Deletion vector | High-performance queries with StarRocks or Hologres. |
|
Merge engine | New data fully replaces old data. |
|
|
| |
Data aggregation is required. |
| |
Only the first record per primary key should be kept. |
| |
Changelog producer | Downstream does not consume the stream. |
|
|
| |
|
| |
Upstream provides complete changelog (e.g. database binlog), |
| |
All other scenarios where downstream needs complete changelog. |
| |
Storage optimization mode | Acceptable latency of 1–3 minutes normally, occasionally 5–10 minutes. | Resource-latency balanced (default) |
Willing to use more resources to reduce latency from resource adjustments. | Latency-first | |
Acceptable latency of about 30 minutes, priority is saving resources. | Resource-first |
Create a primary key table
A Paimon table with a defined primary key is a Paimon primary key table. The following SQL creates a partitioned primary key table with partition key dt and primary key columns dt, shop_id, and user_id.
Flink SQL
CREATE TABLE T (
dt STRING,
shop_id BIGINT,
user_id BIGINT,
num_orders INT,
total_amount INT,
PRIMARY KEY (dt, shop_id, user_id) NOT ENFORCED
) PARTITIONED BY (dt);Spark SQL
CREATE TABLE T (
dt STRING,
shop_id BIGINT,
user_id BIGINT,
num_orders INT,
total_amount INT
) PARTITIONED BY (dt) TBLPROPERTIES (
'primary-key' = 'dt,shop_id,user_id'
);Each row in a Paimon primary key table has a unique primary key. When multiple records with the same primary key are written, they are merged according to the configured merge engine.
The following sections describe the configuration and usage of each core feature: bucketing, deletion vectors, merge engines, and changelog producers.
Bucketing
A bucket is the smallest unit for read and write operations on Paimon primary key tables. Data in a non-partitioned table, or data within each partition of a partitioned table, is divided into multiple buckets to enable parallel reads and writes.
Postponed bucket (default)
To create a postponed-bucketing table, omit the bucket property or set 'bucket' = '-2'.
Data is first written to a bucket-postpone staging directory of a partition. DLF then determines the optimal bucket count for each partition based on data volume, moves the data into the target bucket directories, and compacts small files.
Postponed bucketing is a DLF-enhanced feature with the following advantages:
Automatic bucketing: Bucket count is calculated automatically based on data volume and throughput, with no manual configuration required.
Dynamic adjustment: Bucket count adjusts automatically when data characteristics change significantly.
Partition-level bucketing: Different partitions can use different bucket counts to accommodate varying partition sizes.
Concurrent writes: Multiple jobs can write concurrently without conflicts.
Data distribution
By default, each record is assigned to a bucket based on the hash of its primary key.
Data visibility
Data visibility for postponed-bucketing tables depends on the write method. DLF enables batch write to fixed bucket when the following conditions are met, making data visible immediately after commit:
Deletion vectors are not enabled, and data is written via Flink batch jobs or Spark jobs using
INSERT INTOorINSERT OVERWRITE.Deletion vectors are enabled, and data is written via Flink batch jobs or Spark jobs using
INSERT OVERWRITE.
In all other cases, data becomes visible after DLF processes it, typically within 1–3 minutes. If DLF is performing resource or bucket count adjustments, visibility may take 5–10 minutes. You can select a specific storage optimization mode to reduce adjustment frequency. See Storage optimization modes for details.
When batch write to fixed bucket is enabled, DLF bypasses the bucket-postpone temporary directory and writes data directly into fixed buckets:
Control parameter:
'postpone.batch-write-fixed-bucket'. Set totrueto enable the feature.Supported engine versions: Realtime Compute for Apache Flink VVR 11.4 or later, EMR Serverless Spark esr-4.7.0 or later.
New partition bucket count: When batch write to fixed bucket is enabled and you write to a new partition, the partition bucket count is automatically set to the Flink job parallelism or Spark partition count (max 2048). DLF may adjust this later.
Existing partition write conflicts: When batch write to fixed bucket is enabled and you write to an existing partition, if DLF adjusts the partition's bucket count during the write, the write job will fail due to conflicts. Set
'postpone.batch-write-fixed-bucket' = 'false'to disable this feature and prevent conflicts.Deletion vectors tables: For tables with deletion vectors enabled, disable
'postpone.batch-write-fixed-bucket'when performingINSERT INTOoperations via Flink batch or Spark jobs.
Data consistency
Single-job writes: For data written by the same job, DLF processes records in write order, providing sequential consistency.
NoteA job restarted from a checkpoint or savepoint with unchanged parallelism is treated as the same job.
Multiple-job writes: For data written by different jobs, the final table state may contain a mix of both jobs’ results, but no data is lost, providing snapshot isolation consistency. To control merge order, configure a sequence field. See Handling out-of-order data.
Fixed bucket
Set 'bucket' = '<num>' to specify a fixed bucket count. <num> must be a positive integer.
Too few buckets limit job parallelism and cause excessive data per bucket, degrading read/write performance. However, too many buckets create excessive small files. We recommend a target of approximately 1 GB per bucket.
Data distribution
Same as postponed bucketing. See Data distribution.
Data visibility
Visibility depends on whether deletion vectors are enabled:
Without deletion vectors: data is visible immediately after commit.
With deletion vectors: data becomes visible after the DLF optimization job processes it, typically within 1–3 minutes.
Dynamic bucket
Set 'bucket' = '-1' to enable dynamic bucket.
Limitations:
Dynamic bucket mode does not support concurrent writes from multiple jobs.
For tables larger than 100 GB, dynamic bucketing incurs significant overhead and is not recommended.
Data distribution
Dynamic-bucketing tables write data to existing buckets and automatically create new buckets when a bucket exceeds its capacity. The following properties control behavior:
Property | Type | Default | Description |
| Long | 2000000 | Maximum rows per bucket. |
| Integer | - | Initial bucket count. If unset, defaults to the writer operator parallelism. |
When dynamic bucket is configured, update performance and resource usage depend on whether the primary key fully includes the partition key:
Primary key contains the partition key: Updates remain within the same partition and use heap memory.
Primary key does not fully contain the partition key: Updates may cross partitions, require RocksDB, and can degrade performance.
Non-cross-partition updates
When the primary key fully contains the partition key, DLF can determine the partition but not the bucket for each key, so it maintains an in-memory index mapping keys to bucket IDs. This consumes approximately 1 GB of heap memory per 100 million primary keys. Only actively written partitions consume heap memory.
Apart from heap memory usage, non-cross-partition dynamic bucketing shows no significant performance difference from other bucketing modes.
Cross-partition updates
When the primary key does not fully contain the partition key, DLF uses RocksDB to maintain a mapping from keys to partitions and bucket IDs. This may cause notable performance overhead for large tables, and job startup is slower due to full index loading.
Additionally, the merge engine affects cross-partition update behavior:
deduplicate: data is deleted from the old partition and inserted into the new partition.aggregationandpartial-update: data is updated in the old partition, ignoring the new partition key.first-row: new data is discarded if a record with the same primary key already exists.
Data visibility
Same as fixed bucket. See Data visibility.
Deletion vectors
Set 'deletion-vectors.enabled' = 'true' when creating the Paimon primary key table.
Deletion vectors are generated during small-file compaction to improve query performance on primary key tables. This setting is ideal for query-sensitive or read-heavy workloads.
Deletion vector setting cannot be changed after table creation.
Deletion vectors affect data visibility. See Postponed bucketing: Data visibility and Fixed bucket: Data visibility.
Deletion vectors affect DLF optimization job resource consumption. See Storage optimization modes.
Merge engines
When multiple records with the same primary key are written, Paimon merges them based on the merge-engine property. Supported values: deduplicate (default), partial-update, aggregation, and first-row.
Handling out-of-order data
By default, Paimon uses input order to determine merge priority — the last written record is treated as the latest. For out-of-order data, set 'sequence.field' = '<column-name>' to merge records by the specified column in ascending order.
Supported sequence field types: TINYINT, SMALLINT, INTEGER, BIGINT, TIMESTAMP, TIMESTAMP_LTZ.
deduplicate (default)
With 'merge-engine' = 'deduplicate', only the latest record per primary key is retained. If the latest record is a delete message, all records with that key are removed.
Example:
CREATE TABLE T (
k INT,
v1 DOUBLE,
v2 STRING,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine' = 'deduplicate' -- deduplicate is the default, can be omitted
);Write the following records in order:
+I(1, 2.0, 'apple')+I(1, 4.0, 'banana')+I(1, 8.0, 'cherry')
SELECT * FROM T WHERE k = 1 returns (1, 8.0, 'cherry').
Write the following records in order:
+I(1, 2.0, 'apple')+I(1, 4.0, 'banana')-D(1, 4.0, 'banana')
SELECT * FROM T WHERE k = 1 returns no rows.
aggregation
With 'merge-engine' = 'aggregation', records with the same primary key are aggregated using specified functions. Each non-key column requires fields.<field-name>.aggregate-function; the default is last_non_null_value.
Example:
CREATE TABLE T (
product_id BIGINT,
price DOUBLE,
sales BIGINT,
PRIMARY KEY (product_id) NOT ENFORCED
) WITH (
'merge-engine' = 'aggregation',
'fields.price.aggregate-function' = 'max',
'fields.sales.aggregate-function' = 'sum'
);Write the following records in order:
+I(1, 23.0, 15)+I(1, 30.2, 20)
SELECT * FROM T WHERE product_id = 1 returns (1, 30.2, 35).
Supported aggregate functions
sum: Computes the sum. Types: DECIMAL, TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE.product: Computes the product. Types: DECIMAL, TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE.max: Returns the maximum. Types: CHAR, VARCHAR, DECIMAL, TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE, DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ.min: Returns the minimum. Same types as max.first_value: Returns the first input value (including null). All types.first_not_null_value: Returns the first non-null input value. All types.last_value: Returns the latest input value (including null). All types.last_non_null_value(default): Returns the latest non-null input value. All types.listagg: Concatenates strings with commas. Type: STRING.bool_and/bool_or: Logical AND / OR. Type: BOOLEAN.rbm32/rbm64: Merges 32-bit / 64-bit RoaringBitmaps. Type: VARBINARY.nested_update: Aggregates rows into an ARRAY. Set'fields.<field-name>.nested-key' = 'pk0,pk1,...'to deduplicate by nested key (keeping the last entry).nested_partial_update: Like nested_update but performs partial column updates within the ARRAY by nested key.collect: Merges multiple ARRAYs into one. Set'fields.<field-name>.distinct' = 'true'to deduplicate.merge_map: Merges multiple MAPs, keeping the last value for duplicate keys.
Only sum, product, last_value, last_non_null_value, nested_update, collect, and merge_map support retract messages (update_before and delete). Set 'fields.<field-name>.ignore-retract' = 'true' to make a column ignore retractions.
Example
Use nested_update to aggregate multiple sub orders:
-- Main orders table
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY NOT ENFORCED,
user_name STRING,
address STRING
);
-- Sub order table
CREATE TABLE sub_orders (
order_id BIGINT,
sub_order_id INT,
product_name STRING,
price BIGINT,
PRIMARY KEY (order_id, sub_order_id) NOT ENFORCED
);
-- Wide table
-- Aggregate sub orders
-- And deduplicate sub orders based on sub_order_id
CREATE TABLE order_wide (
order_id BIGINT PRIMARY KEY NOT ENFORCED,
user_name STRING,
address STRING,
sub_orders ARRAY<ROW<sub_order_id BIGINT, product_name STRING, price BIGINT>>
) WITH (
'merge-engine' = 'aggregation',
'fields.sub_orders.aggregate-function' = 'nested_update',
'fields.sub_orders.nested-key' = 'sub_order_id'
);
INSERT INTO order_wide
SELECT
order_id,
user_name,
address,
CAST (NULL AS ARRAY<ROW<sub_order_id BIGINT, product_name STRING, price BIGINT>>)
FROM orders
UNION ALL
SELECT
order_id,
CAST (NULL AS STRING),
CAST (NULL AS STRING),
ARRAY[ROW(sub_order_id, product_name, price)]
FROM sub_orders;Assume the orders table has data:
(1, 'Alice', 'add1'),
(2, 'Bob', 'add2')
The sub_orders table has data:
(1, 11, 'apple', 10),
(1, 12, 'banana', 20),
(2, 21, 'cherry', 30),
(2, 22, 'peach', 40),
(2, 21, 'cherry', 50)
The order_wide table will have data:
(1, 'Alice', 'add1', [(11, 'apple', 10), (12, 'banana', 20)])
(2, 'Bob', 'add2', [(21, 'cherry', 50), (22, 'peach', 40)])
partial-update
With 'merge-engine' = 'partial-update', you can incrementally update a record through multiple messages. New data overwrites old data for the same primary key, except that null values in the new data do not overwrite existing values.
partial-update cannot handle delete or update_before messages. Set 'ignore-delete' = 'true' to ignore them.
Example:
CREATE TABLE T (
k INT,
v1 DOUBLE,
v2 BIGINT,
v3 STRING,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine' = 'partial-update'
);Write the following records in order:
+I(1, 23.0, 10, NULL)+I(1, NULL, NULL, 'This is a book')+I(1, 25.2, NULL, NULL)
SELECT * FROM T WHERE k = 1 returns (1, 25.2, 10, 'This is a book').
Sequence groups
In addition to the global sequence field, you can use sequence groups to specify independent merge ordering for different column groups. This is useful for table-widening scenarios with multiple source tables.
Example:
CREATE TABLE T (
k INT,
a STRING,
b STRING,
g_1 INT,
c STRING,
d STRING,
g_2 INT,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine' = 'partial-update',
'fields.g_1.sequence-group' = 'a,b',
'fields.g_2.sequence-group' = 'c,d'
);Columns a and b use g_1 as their merge sequence (higher value means newer data); columns c and d use g_2 as their merge sequence.
Table widening with aggregation
Within partial-update, you can also apply aggregate functions to specific columns by setting fields.<field-name>.aggregate-function.
The
<field-name>column must belong to a sequence group.All aggregation-engine functions are available. See Supported aggregate functions.
Example:
CREATE TABLE T (
k INT,
a STRING,
b INT,
g_1 INT,
c STRING,
d INT,
g_2 INT,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine' = 'partial-update',
'fields.g_1.sequence-group' = 'a,b',
'fields.b.aggregate-function' = 'max',
'fields.g_2.sequence-group' = 'c,d',
'fields.d.aggregate-function' = 'sum'
);Columns a and b use g_1 as their merge sequence (higher value means newer data): a keeps the latest non-null value (defaults to last_non_null_value), b keeps the maximum. Columns c and d use g_2 as their merge sequence: c keeps the latest non-null value, d computes the sum.
first-row
With 'merge-engine' = 'first-row', DLF keeps only the first record per primary key. Compared to deduplicate, first-row produces only insert-type changelog and does so more efficiently.
Limitations:
first-row cannot handle delete or update_before messages. Set
'ignore-delete' = 'true'to ignore them.first-row does not support sequence fields.
Example:
CREATE TABLE T (
k INT,
v1 DOUBLE,
v2 STRING,
PRIMARY KEY (k) NOT ENFORCED
) WITH (
'merge-engine' = 'first-row'
);Write the following records in order:
+I(1, 2.0, 'apple')+I(1, 4.0, 'banana')+I(1, 8.0, 'cherry')
SELECT * FROM T WHERE k = 1 returns (1, 2.0, 'apple').
Changelog producers
The changelog-producer property controls how Paimon generates changelog for downstream streaming consumers. Common values: none (default), input, and lookup.
none (default)
Suitable when downstream does not consume the stream, or only needs incremental data or the latest state.
With 'changelog-producer' = 'none', downstream reads the incremental data from each Paimon snapshot. The same primary key may appear multiple times:
'merge-engine' = 'deduplicate': downstream reads complete rows.'merge-engine' = 'partial-update': downstream reads only updated columns; others are null.'merge-engine' = 'aggregation': downstream reads only delta values for updated columns; others are null.
When streaming with 'changelog-producer' = 'none', set 'scan.remove-normalize' = 'true' to remove the downstream Flink normalize operator, which is expensive. If you need complete changelog, use 'changelog-producer' = 'input' or 'changelog-producer' = 'lookup' instead.
input
Suitable for 'merge-engine' = 'deduplicate' when downstream needs complete changelog.
With 'changelog-producer' = 'input', Paimon passes input messages directly as changelog. This only works when the input stream is already complete changelog (e.g. database binlog).
The input mechanism involves no extra computation, making it more efficient than lookup.
lookup
Suitable for all scenarios where downstream needs complete changelog.
With 'changelog-producer' = 'lookup', the optimization job performs batch point lookups before each commit, triggering compaction and generating complete changelog from the results. Works regardless of whether the input stream is complete changelog.
The lookup mechanism requires extra computation, making it less efficient than input, but it is more broadly applicable.
Reducing unnecessary changelog
By default, Paimon generates changelog even when the updated value is identical to the previous value. Set 'changelog-producer.row-deduplicate' = 'true' to eliminate such no-op changelog. This applies only to the lookup mechanism.
Use this only when no-op changelog is common, as it introduces additional comparison overhead.
Storage optimization modes
DLF provides multiple storage optimization modes to balance data visibility and resource consumption. This setting is configured in the DLF console. See View and configure storage optimization strategies. DLF supports the following storage optimization modesL
Dynamic resources - Balanced (default): For postponed-bucketing tables, latency is typically 1–3 minutes; may reach 5–10 minutes during resource or bucket adjustments. For other tables with deletion vectors, latency is typically 1–3 minutes.
Dynamic resources - Low latency: Uses approximately 2× the resources of balanced mode to improve optimization job speed and minimize resource adjustments.
Dynamic resources - Low resource usage: Driven by data backpressure. Uses 1/3–1/2 of balanced mode resources, but latency may reach 30 minutes.
Fixed resources: The optimization job runs continuously without adjustments. You can configure resource allocation, compaction interval, and bucket count for postponed-bucketing tables. Recommended only for data catch-up when dynamic mode latency is high; not recommended for daily use.
NoteThe compaction interval refers to DLF optimization job's commit interval. The job continues running; it does not switch to scheduled mode.
After setting the bucket count for postponed-bucketing tables, new partitions use that count directly without adjustment.