Overview
The Kafka ecosystem learned an early lesson from Hadoop: SQL dialects dramatically widen the population of engineers who can build production pipelines. ksqlDB and Flink SQL are the two mainstream answers to “I want to query a stream without writing Java topology code.” Both can produce correct results on unbounded data. The difference is in ownership model, operational surface, and what happens when you need to go off-script.
For a Saudi bank running on IBM Event Streams (Kafka 3.x) or Confluent, the deployment picture is asymmetric: ksqlDB runs inside the Kafka ecosystem with minimal new infrastructure; Flink SQL requires a Flink cluster that needs its own cluster management, scaling strategy, and checkpointing store. That is a legitimate operational cost. But Flink SQL also offers SQL semantics the ksqlDB team openly describes as a future target, not a shipped feature—complex multi-stream joins, MATCH_RECOGNIZE pattern detection, savepoints, and first-class SQL DDL for state management.
If windowing, KTable, and changelog topics are new, read Kafka Streams & Flink first. This article builds on those concepts and translates them into SQL semantics.
ksqlDB vs Flink SQL
Choose the engine once, deliberately. Migrating a production streaming SQL application to a different engine is not a weekend project; the SQL dialects differ in ways that matter, and state must be rebuilt from scratch.
| Property | ksqlDB 0.29 | Flink SQL 1.19 |
|---|---|---|
| Runtime | JVM process, co-located with Kafka cluster | Flink JobManager + TaskManagers (separate cluster) |
| State backend | Kafka changelog topic (RocksDB via Kafka Streams) | RocksDB or Heap; checkpoints to S3/HDFS/NFS |
| SQL completeness | Subset: SELECT, CREATE STREAM/TABLE, GROUP BY, window functions, basic JOINs | Full SQL:2016 subset + streaming extensions; MATCH_RECOGNIZE, TUMBLE/HOP/SESSION/CUMULATE |
| CDC ingestion | Via Kafka Connect debezium source; treats as stream | Native Flink CDC connectors (Debezium, Maxwell); changelog-aware merge |
| Operational surface | One ksqlDB server binary; REST API for DDL/DML | Separate cluster + checkpointing store; Flink UI; savepoint management |
| Scaling | Horizontal: add ksqlDB servers; partitioned by query topic | Horizontal: TaskManager slots; fine-grained parallelism per operator |
| Best fit | Enrichment, filtering, per-topic transformations; smaller teams | Complex multi-stream analytics, regulatory reporting, ML feature pipelines |
| KSA regulatory fit | Simpler SAMA sandbox testing; fewer moving parts for audit | Richer lineage and checkpointing for SOX/SAMA evidence chains |
ksqlDB is open-source but its roadmap is driven by Confluent. If you run IBM Event Streams (Apache Kafka under the hood without Confluent Platform), ksqlDB is deployable but you lose Confluent-specific enhancements. Flink SQL has no such dependency—it works with any Kafka-compatible cluster.
Deployment architecture
The two engines slot into an existing Kafka-based platform differently. ksqlDB extends the Kafka footprint; Flink SQL stands beside it.
Writing streaming queries
Both engines distinguish between a stream (an unbounded append log, backed by a Kafka topic) and a table (the latest value per key, backed by a compacted topic or KTable). Every query is declared against one of these abstractions.
-- Declare an existing Kafka topic as a stream
CREATE STREAM card_auth (
auth_id VARCHAR KEY,
customer_id VARCHAR,
amount DECIMAL(15,2),
mcc VARCHAR,
ts BIGINT
) WITH (
KAFKA_TOPIC = 'card-auth',
VALUE_FORMAT = 'AVRO',
TIMESTAMP = 'ts'
);
-- Declare a compacted topic as a table (latest-value-per-key)
CREATE TABLE customer_profile (
customer_id VARCHAR PRIMARY KEY,
risk_segment VARCHAR,
daily_limit DECIMAL(15,2)
) WITH (
KAFKA_TOPIC = 'customer-master',
VALUE_FORMAT = 'AVRO'
);
-- Push query: subscribe to enriched output (useful for dashboards)
SELECT a.auth_id, a.amount, c.risk_segment
FROM card_auth a
JOIN customer_profile c ON a.customer_id = c.customer_id
EMIT CHANGES;
Flink SQL uses identical logical abstractions but different DDL syntax and connector notation:
-- Source table backed by a Kafka topic (append stream)
CREATE TABLE card_auth (
auth_id STRING,
customer_id STRING,
amount DECIMAL(15,2),
mcc STRING,
ts TIMESTAMP(3),
WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'card-auth',
'properties.bootstrap.servers' = 'kafka:9092',
'format' = 'avro-confluent',
'avro-confluent.schema-registry.url' = 'http://schema-registry:8081'
);
-- Lookup table (upsert Kafka connector for changelog-aware reads)
CREATE TABLE customer_profile (
customer_id STRING PRIMARY KEY NOT ENFORCED,
risk_segment STRING,
daily_limit DECIMAL(15,2)
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'customer-master',
'properties.bootstrap.servers' = 'kafka:9092',
'key.format' = 'raw',
'value.format'= 'avro-confluent',
'value.avro-confluent.schema-registry.url' = 'http://schema-registry:8081'
);
Stateful aggregations
Streaming SQL aggregations are always stateful. The engine must remember previous rows within the scope of a window or a grouping key. Unlike batch SQL, there is no full table scan — only incremental update as each new event arrives. This has two implications: aggregation results are continuously emitted (a retraction stream) and state size is bounded only by the key cardinality and the window duration.
CREATE TABLE customer_spend_10m AS
SELECT
customer_id,
WINDOWSTART AS window_start,
WINDOWEND AS window_end,
COUNT(*) AS tx_count,
SUM(amount) AS total_spend,
MAX(amount) AS max_single_tx
FROM card_auth
WINDOW TUMBLING (SIZE 10 MINUTES)
GROUP BY customer_id
EMIT FINAL; -- only emit once the window closes, not on every row
EMIT CHANGES sends a row to the output topic every time a partial result updates—up to one row per input event. For fraud detection dashboards this is fine. For downstream database inserts or ISO 20022 message generation, use EMIT FINAL to wait until the window closes. Mixing semantics between producers and consumers of derived topics is the most common ksqlDB data quality bug.
Schema management
Schema Registry is non-negotiable in production. It enforces Avro or Protobuf compatibility on every topic, which prevents a schema change in one pipeline from silently breaking all downstream consumers. Both ksqlDB and Flink SQL integrate with the Confluent Schema Registry API.
Two compatibility modes matter for financial data:
- BACKWARD — new readers can read old messages. Safe for adding nullable fields. The default for most transaction topics.
- FULL — new readers can read old messages and old readers can read new messages. Required when you have long-running consumers (like compliance archives) that cannot be redeployed quickly.
Avro treats a rename as a delete + add. If your downstream ksqlDB query depends on customer_id and a schema change renames it to cust_id, ksqlDB silently produces null for that field—no exception, no alert. Register your schema changes with a compatibility check before deploying the producer. Mandate this at the platform level, not per-team.
Windowing in SQL
Both engines support four window types via SQL functions. Flink SQL adds a fifth (CUMULATE) which is particularly useful for regulatory reporting patterns like “running daily total since midnight, updated every minute.”
| Window type | SQL function (Flink) | Financial use case |
|---|---|---|
| Tumbling | TUMBLE(ts, INTERVAL '10' MINUTE) | Hourly settlement batches, transaction count thresholds |
| Hopping (sliding) | HOP(ts, INTERVAL '1' MINUTE, INTERVAL '10' MINUTE) | Rolling 10-minute velocity: transactions in last 10 min, updated each minute |
| Session | SESSION(ts, INTERVAL '30' MINUTE) | Customer session aggregation; ATM sequence detection |
| Cumulate | CUMULATE(ts, INTERVAL '1' MINUTE, INTERVAL '1' DAY) | Daily spend running total for limit enforcement; SAMA daily limits |
| Over (ksqlDB) | WINDOW HOPPING (SIZE 10 MIN, ADVANCE BY 1 MIN) | Sliding fraud velocity; merchant spend concentration |
SAMA’s IPS framework sets per-day transaction limits for individuals and SMEs. A cumulate window lets you compute the running spend from midnight every 1-minute interval without waiting until end-of-day. This is operationally simpler than a session window and better than a tumbling window because the result is always “amount spent since midnight” rather than “amount spent in the last X minutes.”
Stream-table joins
Stream-table joins are the enrichment pattern: every incoming event is enriched with the current state of a reference dataset (customer profile, sanctions list, merchant category). Both engines support this, but the join semantics differ in an important way:
- ksqlDB — stream-table join uses the table state at the time the stream event arrives. There is no time-travel or delayed lookup. This is correct for most enrichment cases.
- Flink SQL — the
FOR SYSTEM_TIME AS OFsyntax enables temporal joins: look up the reference table at the event’s own timestamp, not the current state. This matters for audit-grade enrichment where you need to prove “what was the counterparty’s sanction status at the time of this transaction”—not today.
-- sanctions_list must be a Flink temporal table with versioned rows
CREATE TABLE sanctions_list (
entity_id STRING PRIMARY KEY NOT ENFORCED,
sanction_flag BOOLEAN,
list_date TIMESTAMP(3), -- when this version became effective
WATERMARK FOR list_date AS list_date - INTERVAL '0' SECOND
) WITH ( /* connector props */ );
INSERT INTO payment_screening_output
SELECT
p.payment_id,
p.beneficiary_id,
p.amount,
s.sanction_flag,
s.list_date AS sanction_version_at_event_time
FROM payments p
LEFT JOIN sanctions_list FOR SYSTEM_TIME AS OF p.ts AS s
ON p.beneficiary_id = s.entity_id;
Building a pipeline: step by step
-
Design the topology before writing SQL
Name the input topics, the derived intermediate topics, and the output topics. Define the key for each. SQL makes the code easy to write, but mis-keyed topics still cause silent join misses. Draw the DAG first.
-
Register schemas in Schema Registry
Define Avro schemas for all source topics before writing DDL. Use
BACKWARDcompatibility as the default, upgrading toFULLfor any topic with long-lived consumers. Schema Registry check on eachCREATE TABLEat deploy time, not at runtime. -
Declare streams and tables with explicit watermarks
For Flink SQL, always declare the
WATERMARK FORexpression on the event-time column. The watermark controls window closure and late-event handling. For ksqlDB, setTIMESTAMPin theWITHclause to use event time rather than processing time. -
Write and test queries against a local broker
Use
docker-composewith a single-broker Kafka and ksqlDB/Flink SQL CLI. Inject synthetic events representing normal, edge, and late-arrival cases. Validate output topic contents withkafka-console-consumerbefore deploying upstream. -
Deploy as persistent queries (ksqlDB) or Flink jobs
For ksqlDB, use
CREATE TABLE/STREAM AS SELECTstatements committed to source control and applied via the ksqlDB REST API or Terraform provider. For Flink, package as a JAR or SQL script run viaflink run, versioned alongside the DDL catalog. -
Configure checkpointing and retention
For ksqlDB, set
ksql.streams.state.dirand ensure changelog topics have retention at least as long as the maximum expected restart window. For Flink, configure incremental checkpointing to S3 with interval 30–60 seconds and retained checkpoint count ≥ 3. -
Wire lag monitoring before go-live
Both engines expose consumer group lag metrics. Set alerting thresholds at 2x normal lag. A sustained lag increase without throughput increase signals state restore in progress or a slow query (missing index, cartesian join). Catch it before business notices.
Production concerns
The SQL surface is approachable; the operations are not. Three things that catch teams after initial deployment:
Changing a ksqlDB persistent query (adding a column, changing a join condition) requires terminating the query, optionally resetting the consumer group offset, and re-creating it. This causes a replay from the beginning of the topic retention window, which can take hours on production topic volumes. Plan schema and query evolution carefully—immutable output topics with versioned query names are the safest pattern.
A 10-minute hopping window with a 1-minute advance over 5 million active customers creates 10 overlapping windows per customer = 50 million state entries. At 200 bytes each that is 10 GB of state, replicated across all instances. Calculate state size before you pick a window type. Session windows are worst-case unbounded.
A pull query (SELECT … FROM materialized_table WHERE key = ?) reads the current state of a materialized table synchronously—it is useful for request-response enrichment but does not scale to thousands of concurrent requests per second. Under high load, route pull queries to a dedicated ksqlDB cluster or read the changelog topic directly from a Redis or DynamoDB cache.
Fit alongside batch warehouses
Streaming SQL and batch SQL are complementary, not competing. The operational pattern that works in regulated banking is a lambda-adjacent architecture: the streaming layer handles operational decisions (real-time fraud scoring, limit enforcement) while the batch layer handles the analytical workloads that regulators care about (end-of-day reconciliation, daily transaction file to SAMA, SOX reports).
The bridge between the two is the data lake. Flink SQL has a first-class PRINT sink and a Hudi/Iceberg/Delta connector that writes streaming data into a table format that batch engines (Spark, Trino, BigQuery) can query. ksqlDB can sink to Kafka Connect, which in turn writes to S3 for batch consumption. Neither replaces the other.
| Use case | Layer | Latency target |
|---|---|---|
| Card fraud scoring | Streaming SQL | < 300 ms |
| IPS payment limit check | Streaming SQL (cumulate window) | < 100 ms |
| Sanctions screening | Streaming SQL (temporal join) | < 200 ms |
| Daily reconciliation to SAMA | Batch (Spark / scheduled job) | T+0 before 23:59 |
| SOX financial close reporting | Batch (data warehouse query) | T+1 morning |
| Customer 360 feature store | Both (streaming pre-aggregation, batch refresh) | 5-minute window |
When not to use streaming SQL
Streaming SQL is the wrong choice when:
- The query is interactive and ad-hoc. Analysts running exploratory queries against a ksqlDB server will encounter latency, cost, and resource contention it was not designed for. Use Trino or BigQuery against a data lake for exploratory work.
- The logic requires complex imperative state management. A ksqlDB or Flink SQL query that requires a custom UDF for every meaningful operation is a signal that the Kafka Streams Java API is a better fit. SQL abstracts state; sometimes you need to manipulate it directly.
- You need cross-window joins with large state on both sides. Joining two streams where both have multi-hour windows and millions of distinct keys materialises enormous state on both sides. Unless you have a clear throughput and state budget, this pattern causes production incidents.
- The pipeline is read-once and discarded. A one-time backfill or a quarterly regulatory extract does not justify the operational overhead of a persistent streaming query. Write a Spark job.