Definition
- Temporal join – Enriches each row of a streaming table with the version of a versioned table valid at that row's event time.
- FOR SYSTEM_TIME AS OF – The SQL clause that selects the build-side version matching the probe event's timestamp.
- Versioned table – A changelog keyed by a primary key where each key's value changes over time, like exchange rates.
- State efficiency – Watermarks let Flink clean up old versions no longer needed, keeping state bounded.
- Stream-stream join – A symmetric join matching rows from two event streams within a time window, distinct from a temporal join.
The National Bank of India processes foreign-currency transactions, and the exchange rate changes throughout the day. A temporal join is like asking, for each transaction, "what was the dollar-to-rupee rate at the exact moment this transaction happened," and looking up the rate card that was pinned on the wall at that timestamp, not the current one. A regular stream-stream join is different: it is like matching two live feeds, pairing a trade with its confirmation that arrives within a few minutes.
Understanding the Concepts
What is a temporal join?
A temporal join has two sides. The probe side is the main streaming table, like incoming transactions. The build side is a versioned table where each key's value changes over time, like exchange rates.
-- Probe side: transactions with event time
CREATE TABLE forex_transactions (
txn_id STRING,
currency STRING,
amount DECIMAL(15, 2),
txn_time TIMESTAMP(3),
WATERMARK FOR txn_time AS txn_time - INTERVAL '5' SECOND
) WITH ('connector' = 'kafka', 'topic' = 'nbi-forex-txns',
'format' = 'json',
'properties.bootstrap.servers' = 'kafka.bank.internal:9092');
-- Build side: versioned exchange rates
CREATE TABLE exchange_rates (
currency STRING,
rate_to_inr DECIMAL(10, 4),
update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND,
PRIMARY KEY (currency) NOT ENFORCED
) WITH ('connector' = 'kafka', 'topic' = 'nbi-exchange-rates',
'format' = 'json',
'properties.bootstrap.servers' = 'kafka.bank.internal:9092');
How do you write a temporal join?
The FOR SYSTEM_TIME AS OF clause picks the rate version that was valid at the transaction's event time.
-- Temporal join: get the exchange rate valid at each transaction's time
SELECT
t.txn_id,
t.currency,
t.amount,
r.rate_to_inr,
t.amount * r.rate_to_inr AS amount_inr,
t.txn_time
FROM forex_transactions t
JOIN exchange_rates FOR SYSTEM_TIME AS OF t.txn_time AS r
ON t.currency = r.currency;
-- A transaction at 10:00 AM gets the rate valid at 10:00 AM,
-- even if the rate changed at 10:01 AM.
Exchange rates timeline: USD: 83.20 (09:00) → 83.35 (10:30) → 83.50 (11:00) Transaction at 10:00 AM: 1000 USD Temporal join picks rate = 83.20 (version valid at 10:00) amount_inr = 1000 * 83.20 = 83,200 Transaction at 10:45 AM: 500 USD Temporal join picks rate = 83.35 (version valid at 10:45) amount_inr = 500 * 83.35 = 41,675
Why is point-in-time correctness important?
The lookup is deterministic: reprocessing the same transactions gives the same enriched results because the join selects by event time, not wall clock. This is exactly why temporal joins matter for finance.
package com.bank.flink;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
public class TemporalJoinDemo {
public static void main(String[] args) {
TableEnvironment tEnv = TableEnvironment.create(
EnvironmentSettings.inStreamingMode());
// Register source tables (DDL from above)
// ...
// Temporal join for point-in-time currency conversion
tEnv.executeSql(
"INSERT INTO converted_transactions " +
"SELECT t.txn_id, t.currency, t.amount, " +
" r.rate_to_inr, t.amount * r.rate_to_inr AS amount_inr " +
"FROM forex_transactions t " +
"JOIN exchange_rates FOR SYSTEM_TIME AS OF t.txn_time AS r " +
" ON t.currency = r.currency"
);
// Reprocessing always produces the same result
}
}
How does state management work?
Flink keeps the version history of the build side in state. Watermarks let it clean up versions that can no longer be needed, keeping state bounded.
State management: Watermark on probe side = 10:05 → No transaction with event_time < 10:05 will ever arrive → Exchange rate versions before 10:05 can be cleaned up → Only recent versions are kept in state Without watermarks: → All versions kept forever → unbounded state growth
How does a temporal join differ from a stream-stream join?
A temporal join is asymmetric: one side is an evolving reference. A stream-stream join is symmetric: both sides are event streams matched within a time window.
-- Stream-stream join: match trade to confirmation within 5 minutes
SELECT t.trade_id, t.amount, c.confirmed_at
FROM trades t
JOIN confirmations c
ON t.trade_id = c.trade_id
AND c.confirmed_at BETWEEN t.trade_time AND t.trade_time + INTERVAL '5' MINUTE;
-- Temporal join: enrich trade with rate valid at trade time
SELECT t.trade_id, t.amount, r.rate_to_inr
FROM trades t
JOIN exchange_rates FOR SYSTEM_TIME AS OF t.trade_time AS r
ON t.currency = r.currency;
| Feature | Temporal Join | Stream-Stream Join |
|---|---|---|
| Symmetry | Asymmetric (reference + events) | Symmetric (events + events) |
| Lookup | Point-in-time version | Match within time window |
| State | Version history (bounded by watermark) | Both sides (bounded by window) |
| Use case | Enrichment with rates, prices, configs | Pairing trades with confirmations |
What is the processing-time variant?
The processing-time variant uses FOR SYSTEM_TIME AS OF a processing-time attribute. It looks up the latest version rather than the historically-correct one, used when you only need the current reference.
-- Processing-time temporal join: always gets the latest rate
SELECT t.txn_id, t.amount, r.rate_to_inr
FROM forex_transactions t
JOIN exchange_rates FOR SYSTEM_TIME AS OF t.proc_time AS r
ON t.currency = r.currency;
-- Not deterministic: reprocessing may get different rates
-- Use only when point-in-time correctness is not needed
What are the requirements for the versioned table?
The versioned table needs a primary key and an event-time attribute. The primary key identifies which entity has versions. The event-time attribute defines when each version was valid.
-- CORRECT: versioned table with primary key and event-time watermark
CREATE TABLE exchange_rates (
currency STRING,
rate_to_inr DECIMAL(10, 4),
update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND,
PRIMARY KEY (currency) NOT ENFORCED
) WITH ('connector' = 'kafka', 'topic' = 'rates', 'format' = 'json',
'properties.bootstrap.servers' = 'kafka.bank.internal:9092');
-- WRONG: missing primary key or watermark
-- Without primary key: Flink cannot track versions per entity
-- Without watermark: Flink cannot determine version validity
What should a senior engineer know?
- Temporal joins are far more state-efficient than regular joins because they only retain the needed version history.
- Use event-time temporal joins for correctness-sensitive financial computations like currency conversion.
- Use the processing-time variant only for non-critical enrichment where you just need the latest reference value.
- Watermarks on both sides are essential: probe watermarks drive state cleanup, build watermarks define version boundaries.
// Senior tip: verify temporal join output determinism
// Run the same input twice → same output each time
// This is guaranteed with event-time temporal joins
// but NOT with processing-time temporal joins
Visual Overview
Temporal Join – Point-in-Time Enrichment
Probe side (transactions): Build side (exchange rates):
┌────────────────────┐ ┌────────────────────────┐
│ TXN-001 USD 1000 │ │ USD: 83.20 (09:00) │
│ time: 10:00 │ │ USD: 83.35 (10:30) │
│ │ │ USD: 83.50 (11:00) │
│ TXN-002 USD 500 │ │ │
│ time: 10:45 │ │ │
└─────────┬──────────┘ └────────────┬───────────┘
│ │
└───────────┬───────────────────┘
▼
FOR SYSTEM_TIME AS OF
│
┌───────────▼───────────┐
│ TXN-001: rate = 83.20 │ (version at 10:00)
│ TXN-002: rate = 83.35 │ (version at 10:45)
└───────────────────────┘
Temporal Join vs Stream-Stream Join
Temporal: Stream-Stream:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Events │ │ Reference│ │ Events A │ │ Events B │
│ (probe) │ │ (build) │ │ (trades) │ │ (confirms)│
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
└──┬──────────┘ └──────┬───────┘
▼ ▼
Version at event time Match within window
(asymmetric) (symmetric)
Key Takeaway
A temporal join in Flink SQL enriches each event with the version of a versioned reference table valid at that event's time, using FOR SYSTEM_TIME AS OF. The versioned table is a changelog keyed by a primary key where each key's value changes over time, like exchange rates. For each transaction, Flink looks up the rate version that was in effect at that transaction's timestamp, giving point-in-time-correct, deterministic enrichment.
The mechanics depend on event time and watermarks on both sides. Flink keeps the version history in state but uses watermarks to clean up versions that can no longer be needed, keeping state bounded. A temporal join is asymmetric: one side is an evolving reference, not a symmetric stream of events. A regular stream-stream join matches rows from two event streams within a time window.
At the National Bank of India, every foreign transaction had to be converted to rupees at the rate in effect at the exact moment it occurred. We modeled exchange rates as a versioned table and joined transactions FOR SYSTEM_TIME AS OF the transaction's event time. A morning transaction always got the morning rate, even if it arrived late. Reprocessing gave identical results because the version is selected by event time, not wall clock.
Remember one sentence: a temporal join uses FOR SYSTEM_TIME AS OF to enrich each event with the historically-correct version of a reference table, giving deterministic point-in-time enrichment that financial pipelines require.
---