Build a Streaming Pipeline with dbt and Confluent Cloud for Apache Flink
The dbt-confluent adapter lets you define Confluent Cloud for Apache Flink® pipelines as
dbt models and deploy them with standard dbt
commands, so the version control, testing, and documentation workflow your
team uses for batch analytics also applies to streaming transformations on
Confluent Cloud.
This page is a complete, end-to-end tutorial. You start from an empty directory and finish with a running, tested pipeline that you evolve in place. Every step builds on the previous one, so work through them in order. For every materialization, configuration option, and adapter behavior, see dbt Adapter Reference for Confluent Cloud for Apache Flink.
What you build
You build a four-model order-processing pipeline:
raw_orders streaming_source Faker connector generates order events
└─ stg_orders materialized_table Cleans and stores the orders in a Kafka topic
├─ high_value_orders materialized_table Orders at or above an amount threshold
└─ customer_order_stats materialized_table Running order count and spend per customer
In this tutorial, you perform the following steps:
Tip
To see a similar pipeline built live, watch Demo: Create streaming data pipelines with the dbt adapter and Materialized Tables.
Prerequisites
You need the following prerequisites to complete this tutorial:
A Confluent Cloud environment and a Kafka cluster in it. Record the environment ID (
env-xxxxxx), your organization ID, and the cluster name. The adapter uses the cluster name, not the cluster ID (lkc-xxxxxx).A compute pool in the same environment and region as the cluster. Record its ID (
lfcp-xxxxx). If default compute pools are enabled for your organization, you can skip the pool and let Flink use the default one.A global API key for a service account that has the
FlinkDeveloperrole and permission to create topics and schemas in the cluster. For the required roles, see Grant Role-Based Access in Confluent Cloud for Apache Flink. A Flink API key also works for every step except the optional Tableflow step, which requires a global API key.Python 3.10, 3.11, 3.12, or 3.13. Python 3.14 is not supported.
Note
If your organization inspects TLS traffic with a proxy, such as Zscaler, read Certificate errors behind a TLS inspection proxy before you install the adapter.
Step 1: Install the dbt-confluent adapter
Create and activate a Python virtual environment:
python3 -m venv .venv source .venv/bin/activate
Install the adapter:
pip install dbt-confluent
The adapter installs the version of dbt Core it supports, currently 1.11.x. Don’t install or upgrade
dbt-coreseparately. dbt Core 1.12 is not compatible with the adapter.Verify the installation:
dbt --versionThe output lists
confluentunder Plugins. The output can also say that a newer version of dbt Core is available. Don’t upgrade dbt Core; the adapter supports only the version it installed.
Step 2: Create and connect a dbt project
Create a new dbt project:
dbt init orders_pipeline
Select
confluentas the adapter, then answer the prompts:Prompt
What to enter
Backend URL
Choose Use cloud_provider and cloud_region, then enter your cloud provider, for example
aws, and region, for exampleus-east-2. If you connect over private networking, choose Use a custom endpoint instead. For more information, see Profile configuration.organization_idYour Confluent Cloud organization ID.
Compute pool
Choose Use a specific compute pool and enter your pool ID, or choose the default compute pool option.
environment_idYour environment ID, for example
env-xxxxxx.dbnameThe name of your Kafka cluster.
API credentials
Choose Use a Global API key and enter the key and secret. If you have a Flink API key instead, choose Use a Flink-region API key. The profile then has
flink_api_keyandflink_api_secretinstead of the global key settings, and you skip Step 8.execution_mode,statement_name_prefix,statement_label,threadsAccept the defaults.
dbt initwrites the answers to~/.dbt/profiles.ymland creates anorders_pipelineproject directory.Move into the project directory and delete the example models that
dbt initgenerates:cd orders_pipeline rm -rf models/example
The example models select an untyped
NULL, which Flink SQL rejects, sodbt runfails if you leave them in place.Open
dbt_project.ymland replace themodels:block at the end of the file with the following. This sets a default materialization for each directory you create in the next steps:models: orders_pipeline: raw: +materialized: streaming_source staging: +materialized: materialized_table marts: +materialized: materialized_table
Open
~/.dbt/profiles.yml. Theorders_pipelineprofile resembles the following. To keep the secret out of the file, replace it with anenv_var()lookup:orders_pipeline: target: dev outputs: dev: type: confluent cloud_provider: aws cloud_region: us-east-2 organization_id: <your-organization-id> environment_id: <your-environment-id> compute_pool_id: <your-compute-pool-id> dbname: <your-kafka-cluster-name> global_api_key: <your-global-api-key> global_api_secret: "{{ env_var('CONFLUENT_GLOBAL_API_SECRET') }}" execution_mode: streaming_query statement_name_prefix: dbt- statement_label: dbt-confluent threads: 1
If you chose a Flink API key, the profile has
flink_api_keyandflink_api_secretin place ofglobal_api_keyandglobal_api_secret. If you use anenv_var()lookup, export the variable in your shell:export CONFLUENT_GLOBAL_API_SECRET=<your-global-api-secret>
Verify the connection:
dbt debugIf the connection succeeds, the output ends with
All checks passed!. If it fails, see Troubleshooting.
Step 3: Generate sample data with a streaming source
A streaming_source model creates a table with a Flink SQL connector.
Instead of a SELECT statement, the model body is the table’s column list.
This tutorial uses the faker connector, which
generates sample data, so you don’t
need a producer.
A Faker table doesn’t store data. Each statement that reads from it gets its own freshly generated rows. In the next step, you store the generated orders in a Kafka topic once, so that every downstream model reads the same orders.
Create models/raw/raw_orders.sql:
{{ config(
connector='faker',
with={
'rows-per-second': '5',
'fields.order_id.expression': "#{number.numberBetween '1','1000000'}",
'fields.customer_id.expression': "#{number.numberBetween '1','500'}",
'fields.product_name.expression': '#{Commerce.productName}',
'fields.amount.expression': "#{number.randomDouble '2','5','500'}",
'fields.status.expression': "#{options.option 'PLACED',' shipped ','Delivered','processing'}",
'fields.event_time.expression': "#{date.past '30','SECONDS'}",
}
) }}
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
product_name STRING NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status STRING NOT NULL,
event_time TIMESTAMP(3) NOT NULL
The status values are deliberately inconsistent in case and whitespace so
that the staging model has something to clean. Write single quotes inside
with values as-is. The adapter escapes them when it renders the
CREATE TABLE statement.
Step 4: Write the transformation models
Create
models/staging/stg_orders.sql. Amaterialized_tablemodel submits aCREATE OR ALTER MATERIALIZED TABLEstatement. Flink then runs the query continuously and writes its results to the table’s Kafka topic:SELECT order_id, customer_id, product_name, LOWER(TRIM(status)) AS status, amount, event_time FROM {{ ref('raw_orders') }} WHERE amount > 0
{{ ref('raw_orders') }}tells dbt that this model depends onraw_orders, so dbt always deploysraw_ordersfirst.Because
stg_ordersstores the cleaned orders in a topic, both of the following models read the same orders, and Flink can reprocess them later. Ifstg_orderswere aview, each downstream model would read its own separately generated Faker data.Create
models/marts/high_value_orders.sql:SELECT order_id, customer_id, product_name, amount, event_time FROM {{ ref('stg_orders') }} WHERE amount >= 300
This query is stateless: each output row depends only on one input row.
Create
models/marts/customer_order_stats.sql:{{ config( distributed_by={'columns': ['customer_id']}, ) }} SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS lifetime_value FROM {{ ref('stg_orders') }} GROUP BY customer_id
This query is stateful: Flink keeps a running count and total for each customer. The
distributed_byconfiguration partitions the table bycustomer_id. Distribution columns must come first in theSELECTlist.
Your project now contains these models:
orders_pipeline/
├── dbt_project.yml
└── models/
├── raw/
│ └── raw_orders.sql
├── staging/
│ └── stg_orders.sql
└── marts/
├── high_value_orders.sql
└── customer_order_stats.sql
Step 5: Deploy the pipeline
Deploy every model:
dbt rundbt submits the models in dependency order:
raw_orders, thenstg_orders, then the two marts. The output reportsCompleted successfullyandPASS=4.In the Cloud Console, open your Kafka cluster. The
stg_orders,high_value_orders, andcustomer_order_statstopics exist. Openhigh_value_ordersand view its messages. Every order has anamountof at least 300. The first results can take a minute or two to appear.Run the same command again:
dbt runThe second run is safe. The adapter skips
raw_ordersbecause its schema hasn’t changed, and re-asserts each materialized table. An unchanged materialized table definition is a no-op in Flink, so no data is dropped or reprocessed.
Step 6: Test the pipeline
dbt supports two kinds of tests. Unit tests check model logic against mock input rows. Data tests check the data that a deployed model produces.
Create
models/staging/stg_orders.yml:version: 2 models: - name: stg_orders columns: - name: order_id data_tests: - not_null - name: status data_tests: - not_null - accepted_values: arguments: values: ['placed', 'processing', 'shipped', 'delivered'] unit_tests: - name: test_stg_orders_cleans_status_and_drops_invalid_amounts model: stg_orders given: - input: ref('raw_orders') rows: - {order_id: 1, customer_id: 10, product_name: 'Desk', amount: 350.00, status: ' SHIPPED ', event_time: '2026-01-15 10:00:00'} - {order_id: 2, customer_id: 11, product_name: 'Lamp', amount: 0.00, status: 'placed', event_time: '2026-01-15 10:01:00'} expect: rows: - {order_id: 1, customer_id: 10, product_name: 'Desk', amount: 350.00, status: 'shipped', event_time: '2026-01-15 10:00:00'}
The unit test feeds two mock orders into
stg_orders. The test expects the first order’s status to come out lowercase and trimmed, and the second order to be dropped because its amount is zero.Create a custom data test,
tests/assert_high_value_threshold.sql. A custom test passes when its query returns no rows:SELECT order_id, amount FROM {{ ref('high_value_orders') }} WHERE amount < 300
Run all the tests. Unit tests need the models and their inputs to exist, which is why you deployed the pipeline first:
dbt test
To run only the unit tests, run
dbt test --select "test_type:unit". For each unit test, the adapter creates temporary tables that copy the schema of the model’s inputs, inserts the mock rows, runs the model’s SQL, and compares the result with the expected rows. It drops the temporary tables when the test finishes.Each data test checks every order that is in
stg_ordersorhigh_value_orderswhen the test runs. Orders that arrive later are checked the next time you rundbt test.
Step 7: Evolve a materialized table in place
With a materialized_table model, you change the SQL and run dbt run
again. Flink evolves the running table in place, so you don’t drop the table
or manage offsets. How the change affects existing results depends on
whether the query is stateless or stateful.
Change a stateless model
In
models/marts/high_value_orders.sql, lower the threshold and add thestatuscolumn:SELECT order_id, customer_id, product_name, amount, event_time, status FROM {{ ref('stg_orders') }} WHERE amount >= 200
Deploy only the changed model:
dbt run --select high_value_orders
In the
high_value_orderstopic, new messages includestatus, and orders between 200 and 300 now appear. Records written before the change stay as they are. The evolved query can take a minute or two to start writing new results.In
tests/assert_high_value_threshold.sql, change300to200so that the test matches the new logic.
For a stateless query under the default start mode,
RESUME_OR_FROM_BEGINNING, Flink resumes from its saved offsets if it can,
so the change applies to new records without reprocessing or duplicating
earlier ones. For what happens if it can’t, see
Evolution and state.
Change a stateful model
In
models/marts/customer_order_stats.sql, add a column:{{ config( distributed_by={'columns': ['customer_id']}, ) }} SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS lifetime_value, MAX(amount) AS largest_order FROM {{ ref('stg_orders') }} GROUP BY customer_id
Deploy the change:
dbt run --select customer_order_stats
When a stateful query evolves, Flink discards the query’s state, which here is
the running count and total for each customer, and resumes from its saved
offsets. When a customer places a new order, Flink overwrites that customer’s
row with a count and total that include only orders since the change.
Customers with no new orders keep their old rows, which have no
largest_order value, so the table mixes old and new results.
To recompute the aggregates from all the orders in the stg_orders
topic, rebuild the table with --full-refresh. The rebuilt table has no
saved offsets, so the default start mode reads stg_orders from the
beginning:
dbt run --select customer_order_stats --full-refresh
Important
--full-refresh drops and recreates the table. This deletes the
backing Kafka topic, all of its records, and its Schema Registry schemas, which
affects anything that consumes the topic. For more information, see
Evolution and state.
Some changes can’t evolve in place. For example, removing a NOT NULL
column, such as product_name in high_value_orders, or changing
distributed_by fails the run and requires --full-refresh.
Step 8: (Optional) Publish a model with Tableflow
Tableflow materializes the Kafka topic behind a table
as an Apache Iceberg™ or Delta Lake table that analytics engines can query. You
can enable it on a model with the tableflow configuration. This step
requires a global API key in your profile and the
Tableflow prerequisites.
Add a
config()block to the top ofmodels/marts/high_value_orders.sql:{{ config( tableflow={ 'table_formats': ['ICEBERG'], 'storage': {'kind': 'Managed'}, } ) }}
Deploy the change:
dbt run --select high_value_orders
In the Cloud Console, open the
high_value_orderstopic. The Tableflow tab shows Iceberg enabled with Confluent-managed storage.
The adapter checks Tableflow on every run. It enables Tableflow if it’s off and updates the settings in place if you change them. For storage options and limitations, see tableflow.
Step 9: Clean up
The materialized tables run continuously and consume compute pool resources until you remove them.
If you enabled Tableflow in Step 8, disable it on the
high_value_orderstopic in the Cloud Console.In a workspace or the Flink SQL shell, with your environment and cluster selected, drop the tables, starting with the downstream ones:
DROP MATERIALIZED TABLE `customer_order_stats`; DROP MATERIALIZED TABLE `high_value_orders`; DROP MATERIALIZED TABLE `stg_orders`; DROP TABLE `raw_orders`;
Next steps
Read dbt Adapter Reference for Confluent Cloud for Apache Flink for every materialization and configuration option, including
streaming_table, schema drift detection, and adopting statements that you deployed outside dbt.Set up automated deployments with CI/CD.
Learn how materialized tables work in Materialized Tables in Confluent Cloud for Apache Flink.