Back to Apache Flink
Q11Question 11 of 33

What is a temporal join in Flink SQL

Key Concepts
  • A temporal join enriches each event with the version of a reference table valid at that event's time.
  • FOR SYSTEM_TIME AS OF selects the build-side version that was in effect at the probe event's timestamp.
  • The versioned table needs a primary key and an event-time attribute to define its version history.
  • Watermarks drive both correctness and state cleanup, keeping only the needed version history in memory.
  • 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.
  • The processing-time variant looks up the latest version, used when point-in-time correctness is not needed.

Definition

A temporal join enriches each event with the reference-table version valid at that event's time via FOR SYSTEM_TIME AS OF, unlike a stream-stream join that matches two event streams within a window.
  • 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.

SQL
-- 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.

SQL
-- 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.
DIAGRAM
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.

JAVA
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.

DIAGRAM
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.

SQL
-- 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.

SQL
-- 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.

SQL
-- 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.
JAVA
// 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

DIAGRAM
  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)
            └───────────────────────┘
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate