Lookup Joins
Lookup Joins are a type of join in streaming queries. It is used to enrich a table with data that is queried from Paimon. The join requires one table to have a processing time attribute and the other table to be backed by a lookup source connector.
Paimon supports lookup joins on tables with primary keys and append tables in Flink. The following example illustrates this feature.
Choose a Lookup Strategy
| Situation | Approach | Constraint |
|---|---|---|
| Dimension data fits in each lookup task | Normal lookup | Each task maintains a local copy by default. |
| Dimension updates arrive after the main stream | Retry lookup | A miss can delay processing; retries are bounded by the configured attempts. |
| Retries block unrelated records | Async retry | Unordered output applies to append streams; CDC inputs have additional ordering constraints. |
| A full local copy is too large | Shuffle lookup | Flink 2.0+, fixed buckets, and join keys containing all bucket keys. |
| Only the newest partition is needed | Dynamic partition | Configure partition selection and refresh interval. |
| A shared query service is running | Query Service | Requires a fixed-bucket primary-key table. |
Prepare
This example uses Flink's built-in DataGen connector and a small Paimon dimension table. Complete Installation first. Run the SQL in a new session, or use different table names if they already exist.
Create and Populate the Dimension Table
CREATE CATALOG my_catalog WITH (
'type' = 'paimon',
'warehouse' = 'file:/tmp/paimon-lookup'
);
USE CATALOG my_catalog;
SET 'execution.runtime-mode' = 'batch';
SET 'table.dml-sync' = 'true';
SET 'parallelism.default' = '1';
CREATE TABLE customers (
id INT PRIMARY KEY NOT ENFORCED,
name STRING,
country STRING,
zip STRING
) WITH ('bucket' = '4');
INSERT INTO customers VALUES
(1, 'Alice', 'US', '10001'),
(2, 'Bob', 'DE', '10115'),
(3, 'Chen', 'CN', '200000');
Wait for the insert to finish before starting the lookup query. In a production pipeline, a separate streaming writer can keep the dimension table up to date. Use a warehouse on shared storage for a distributed cluster.
Create the Incoming Stream
SET 'execution.runtime-mode' = 'streaming';
CREATE TEMPORARY TABLE orders (
order_id INT,
total INT,
customer_id INT,
proc_time AS PROCTIME()
) WITH (
'connector' = 'datagen',
'rows-per-second' = '1',
'fields.order_id.kind' = 'sequence',
'fields.order_id.start' = '1',
'fields.order_id.end' = '3',
'fields.customer_id.kind' = 'sequence',
'fields.customer_id.start' = '1',
'fields.customer_id.end' = '3',
'fields.total.min' = '10',
'fields.total.max' = '100'
);
The source emits three orders and finishes. The proc_time column provides the processing-time
attribute required by the lookup join. Replace this temporary table with your Kafka or other
streaming source when adapting the example.
Normal Lookup
Use customers as the lookup side of the join. Each order is enriched using the dimension data
available to the lookup operator when the record is processed. This is a processing-time lookup,
not a historical read of the dimension table at the order's event time.
-- enrich each order with customer information
SELECT o.order_id, o.total, c.country, c.zip
FROM orders AS o
JOIN customers
FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
The example returns one enriched row for each of the three orders. An inner JOIN drops an
order when no customer matches; use LEFT JOIN to retain it with null dimension fields.
Retry Lookup
Use a delayed retry when an order arrives before its customer is visible to the lookup operator. Flink 1.16+ supports a delayed retry strategy. The example retries a lookup miss with a one-second delay and at most 600 attempts. Choose the retry budget for the expected dimension-data delay.
-- enrich each order with customer information
SELECT /*+ LOOKUP(
'table' = 'c',
'retry-predicate' = 'lookup_miss',
'retry-strategy' = 'fixed_delay',
'fixed-delay' = '1s',
'max-attempts' = '600'
) */
o.order_id, o.total, c.country, c.zip
FROM orders AS o
JOIN customers
FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
Async Retry Lookup
Synchronous retries delay subsequent records in the same lookup task. Enable asynchronous lookup and allow unordered output to let other completed lookups proceed while a miss is retried.
-- enrich each order with customer information
SELECT /*+ LOOKUP(
'table' = 'c',
'retry-predicate' = 'lookup_miss',
'output-mode' = 'allow_unordered',
'retry-strategy' = 'fixed_delay',
'fixed-delay' = '1s',
'max-attempts' = '600'
) */
o.order_id, o.total, c.country, c.zip
FROM orders AS o
JOIN customers /*+ OPTIONS('lookup.async'='true', 'lookup.async-thread-number'='16') */
FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
Flink only allows unordered lookup output for append streams. For a CDC input, ordering constraints can still delay subsequent records. An audit-log table exposes changes as append records, but the query must then interpret the row kinds explicitly; it is not a transparent replacement for a CDC stream.
Large Scale Lookup (Fixed Bucket)
By default, each Flink subtask would store a whole copy of the lookup table. If the amount of data in customers
(lookup table) is too large for a single subtask, you can enable the shuffle lookup optimization as follows
(For Flink 2.0+ and fixed-bucket Paimon table). This optimization enables sending data of the same bucket to designated
subtask(s), so each Flink subtask would only need to store a part of the whole data.
-- enrich each order with customer information
SELECT /*+ LOOKUP('table'='c', 'shuffle'='true') */
o.order_id, o.total, c.country, c.zip
FROM orders AS o
JOIN customers
FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
This requires Flink 2.0+, a fixed-bucket dimension table, and join keys containing all bucket
keys. The customers table above uses four fixed buckets. Choose the bucket count for your workload;
see Data Distribution.
Dynamic Partition
When each partition contains a complete dimension snapshot, use max_pt() to select the
partition with the largest partition value. The lookup operator periodically refreshes the
selection. The following example uses a separate table from the unpartitioned customers above.
Create Paimon Partitioned Table
CREATE TABLE partitioned_customers (
id INT,
name STRING,
country STRING,
zip STRING,
dt STRING,
PRIMARY KEY (id, dt) NOT ENFORCED
) PARTITIONED BY (dt);
Lookup Join
SELECT o.order_id, o.total, c.country, c.zip
FROM orders AS o
JOIN partitioned_customers /*+ OPTIONS(
'scan.partitions' = 'max_pt()',
'lookup.dynamic-partition.refresh-interval' = '1 h'
) */
FOR SYSTEM_TIME AS OF o.proc_time AS c
ON o.customer_id = c.id;
The Lookup node will automatically refresh the latest partition and query the data of the latest partition.
The option scan.partitions can also specify fixed partitions in the form of key1=value1,key2=value2.
Multiple partitions should be separated by semicolon (;).
When specifying fixed partitions, this option can also be used in batch joins.
The option scan.partitions can also specify max_pt() for parent partition in the form of key1=max_pt(),key2=max_pt().
All subpartitions for the latest parent partition will be loaded. For example, if partition keys is 'year', 'day', 'hh',
you can specify year=max_pt(), it will find the latest partition for year and load all its subpartitions for lookup.
Only supports partitions to be specified hierarchically. For example, if setting year=max_pt(),hh=max_pt(), hh=max_pt()
makes no sense.
Query Service
A query service runs as a separate Flink streaming job. It requires a primary-key table with
fixed buckets, such as the customers table in this example. When a query service is available,
lookup joins prioritize it. Allocate enough cluster resources for both the service and the
lookup job.
- Flink SQL
- Flink Action
SET 'execution.runtime-mode' = 'streaming';
SET 'table.dml-sync' = 'false';
CALL sys.query_service('default.customers', 1);
<FLINK_HOME>/bin/flink run \
/path/to/paimon-flink-action-2.2-SNAPSHOT.jar \
query_service \
--warehouse <warehouse-path> \
--database <database-name> \
--table <table-name> \
[--parallelism <parallelism>] \
[--catalog_conf <paimon-catalog-conf> [--catalog_conf <paimon-catalog-conf> ...]]
Verify the Result
If a lookup returns no match, first query the dimension table in batch mode and check the join keys. If the data exists, check the lookup's selected partitions and refresh settings. Retrying cannot fix an incorrect key or a partition excluded by the query.
See Troubleshooting for the full diagnostic sequence.