Kafka → Iceberg
Five ways to get Kafka topics into Apache Iceberg tables, a complete SQL walkthrough with RisingWave, and the design decisions that decide whether the table is still fast in six months: commit interval, small files, upsert vs append-only, catalog and compaction.
Last reviewed 2026-11-08. Every statement below is current RisingWave syntax; links go to the reference docs.
Options
Kafka holds events for days; Iceberg holds them for years and lets every query engine read them. Moving data between the two is now a solved problem in five different ways, and the right one depends on a single question: does the data need to change shape on the way? If the answer is no, pick the simplest copier. If yes, you need a stream processor, and the rest of this page is about doing that in SQL.
| Option | Transforms | Latency to Iceberg | What you operate | Pick it when |
|---|---|---|---|---|
| Kafka Connect Iceberg sink | Single-message transforms only. No joins, no aggregation, no dedup across messages. | Commit interval, typically minutes. | A Connect cluster plus connector config; exactly-once via a control topic. | Faithful copy of a topic, schema already final. |
| Confluent Tableflow / Redpanda Iceberg Topics | None. The topic is materialized as a table as-is (Tableflow can apply a registered schema). | Minutes. | Managed by the broker vendor; tied to that platform. | You are already on that platform and only need raw tables. |
| Apache Flink (DataStream or Flink SQL) | Full stream processing, including custom code. | Seconds to minutes, set by the checkpoint interval. | Flink cluster, state backend, checkpoints, JVM tuning, job restarts. | You need custom operators or already run Flink. |
| Spark Structured Streaming | Full, in micro-batches. | Minutes. | Spark cluster and scheduler; same stack as batch Spark. | Latency in minutes is fine and the team is Spark-native. |
| RisingWave | Full SQL: joins (including with CDC tables and Iceberg tables), aggregations, windows, dedup, UDFs. | About a second inside RisingWave; Iceberg visibility set by commit interval (60s default, tunable to seconds). | One system; state on object storage; built-in compaction and snapshot expiration. | The data has to change shape, or several tables come from one topic. |
Sources: Iceberg Kafka Connect docs, Confluent Tableflow, Redpanda Iceberg Topics, Iceberg Flink docs, Iceberg Spark Structured Streaming docs, RisingWave Iceberg docs.
Walkthrough
The example takes a clickstream topic in Avro, removes bot traffic and duplicate events, enriches each click with page metadata from PostgreSQL, and lands two tables in the lake: the clean events and a one-minute rollup. It is four steps and about sixty lines of SQL. Replace the broker, catalog and credential placeholders with your own.
A source is a continuously read stream; it does not store the topic inside RisingWave. The CDC table does store the Postgres rows and keeps them current, so the join in the next step sees page metadata changes within seconds.
-- Step 1: the Kafka topic as a continuously read source.
-- Avro with Confluent Schema Registry; columns come from the schema.
CREATE SOURCE clicks
INCLUDE timestamp AS kafka_ts
WITH (
connector = 'kafka',
topic = 'web.clicks',
properties.bootstrap.server = 'broker-1:9092,broker-2:9092',
scan.startup.mode = 'latest'
) FORMAT PLAIN ENCODE AVRO (
schema.registry = 'http://schema-registry:8081'
);
-- Reference data from Postgres via CDC, kept in sync automatically.
CREATE TABLE pages (
page_id BIGINT PRIMARY KEY,
site VARCHAR,
category VARCHAR
) WITH (
connector = 'postgres-cdc',
hostname = 'pg', port = '5432',
username = 'rw', password = '...',
database.name = 'cms', schema.name = 'public',
table.name = 'pages'
);Materialized views in RisingWave are maintained incrementally: each new Kafka message updates only the rows it affects. The rollup view reads from the clean view, so both stay consistent with each other. See how incremental materialized views work for the mechanics.
-- Step 2: shape the data once, incrementally.
-- Dedup by event id, drop bots, enrich with page metadata.
CREATE MATERIALIZED VIEW clean_clicks AS
SELECT DISTINCT ON (c.event_id)
c.event_id,
c.user_id,
c.page_id,
p.site,
p.category,
c.event_ts
FROM clicks c
JOIN pages p ON c.page_id = p.page_id
WHERE c.user_agent NOT ILIKE '%bot%'
ORDER BY c.event_id, c.event_ts;
-- A second table from the same topic: one-minute rollups.
CREATE MATERIALIZED VIEW clicks_per_minute AS
SELECT window_start, site, category, COUNT(*) AS clicks
FROM TUMBLE(clean_clicks, event_ts, INTERVAL '1 minute')
GROUP BY window_start, site, category;Use this when Spark, Trino, Snowflake or Databricks already read from a Glue, REST, Hive or S3 Tables catalog and you want the new tables to appear next to the old ones. The first sink is append-only; the second is an upsert so that a late click revises its minute instead of adding a second row.
-- Step 3a: sink into an existing Iceberg catalog (here: AWS Glue).
CREATE SINK clean_clicks_to_lake FROM clean_clicks
WITH (
connector = 'iceberg',
type = 'append-only',
catalog.type = 'glue',
catalog.name = 'lake',
warehouse.path = 's3://my-lake/warehouse',
database.name = 'web',
table.name = 'clean_clicks',
create_table_if_not_exists = 'true',
partition_by = 'day(event_ts),bucket(16,user_id)',
commit_checkpoint_interval = 60,
auto.schema.change = 'true',
s3.region = 'us-west-2',
s3.access.key = '...',
s3.secret.key = '...'
);
-- Upsert variant for the rollup: late events update the minute row.
CREATE SINK rollups_to_lake FROM clicks_per_minute
WITH (
connector = 'iceberg',
type = 'upsert',
primary_key = 'window_start,site,category',
write_mode = 'merge-on-read',
catalog.type = 'glue',
catalog.name = 'lake',
warehouse.path = 's3://my-lake/warehouse',
database.name = 'web',
table.name = 'clicks_per_minute',
create_table_if_not_exists = 'true',
s3.region = 'us-west-2',
s3.access.key = '...',
s3.secret.key = '...'
);With ENGINE = iceberg the table is a normal RisingWave table whose storage is Iceberg on your bucket, with RisingWave hosting the catalog. You can query it, index it, build views on it, and still read it from any other engine through the catalog. Compaction and snapshot expiration are switched on in the same statement.
-- Step 3b (alternative): let RisingWave own the Iceberg table.
CREATE CONNECTION lake_conn WITH (
type = 'iceberg',
hosted_catalog = true,
warehouse.path = 's3://my-lake/warehouse/',
s3.region = 'us-west-2',
s3.access.key = '...',
s3.secret.key = '...'
);
SET iceberg_engine_connection = 'public.lake_conn';
CREATE TABLE clean_clicks_iceberg (
event_id BIGINT PRIMARY KEY,
user_id BIGINT,
page_id BIGINT,
site VARCHAR,
category VARCHAR,
event_ts TIMESTAMPTZ
) WITH (
commit_checkpoint_interval = 60,
enable_compaction = true,
enable_snapshot_expiration = true
) ENGINE = iceberg;
CREATE SINK fill_iceberg INTO clean_clicks_iceberg FROM clean_clicks;
-- Step 4: it is a normal Iceberg table. Query it here ...
SELECT site, COUNT(*) FROM clean_clicks_iceberg GROUP BY site;
-- ... or from Spark, Trino, DuckDB, Snowflake or Databricks
-- through the same catalog and warehouse path.For a runnable local version of this pipeline with Kafka, Lakekeeper, MinIO and DuckDB in Docker Compose, see the Iceberg demo in awesome-stream-processing, and for AWS-native setups the 10-minute Iceberg quick start (built-in catalog or S3 Tables).
Decisions
Getting the first rows into Iceberg takes twenty minutes. Keeping the table fast after six months of continuous writes depends on six choices, and they apply whichever tool writes the table.
Raw events are append-only. Anything with a primary key that can change (CDC tables, windowed aggregates that late data can revise) needs type = 'upsert' and a primary_key. Upsert writes Iceberg delete files, which readers must merge; that is the price of correctness for changing data.
Merge-on-read (default) writes deletes as separate files and is cheapest for continuous ingestion. Copy-on-write rewrites data files on every update and is only available for upsert; choose it when the table is read far more than it is written and your readers are slow at applying delete files.
Each commit is a snapshot and at least one file per partition. Sixty seconds is a sensible default for analytics tables. Go lower only for tables where a human or an agent is waiting on the result, and pair it with compaction.
Use Iceberg hidden partitioning on the column readers filter by, usually a time column with day() or hour(), plus a bucket() on a high-cardinality key if queries are selective on it. Over-partitioning multiplies small files; a streaming writer should rarely have more than a few hundred live partitions.
The catalog is what makes the table visible to other engines. Use Glue if your readers are on AWS, a REST catalog (Lakekeeper, Polaris) for a vendor-neutral setup, S3 Tables for an AWS-managed Iceberg service, or RisingWave's hosted catalog when RisingWave owns the table and others only read it.
Compaction merges small files; snapshot expiration deletes files no snapshot references. RisingWave can run both itself (enable_compaction, enable_snapshot_expiration, with a dedicated compactor node) or you can leave it to Spark or EMR. Without either, a streaming Iceberg table gets slower every day.
Reference: write modes, table maintenance, catalogs, Iceberg partition transforms.
Fit
RisingWave earns its place when the pipeline has logic in it: deduplication, joins with CDC or lake tables, windowed aggregates, several output tables from one topic, or a requirement that the transformed result also be queryable in about a second before it reaches the lake.
One source, one view, one sink. Query the table from RisingWave and from your lake engines.
Start Free