Kafka Connect

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Kafka Producers and Kafka Consumers are what an application reaches for when it owns the code on both ends of a topic. Kafka Connect is a different tool for a different job: an integration framework, running as its own cluster of processes, that moves data between Kafka topics and external systems — databases, search indexes, object stores, other message brokers — using pre-built, configuration-driven connectors rather than hand-written producer/consumer code. This page covers Connect’s architecture, when it’s the right tool versus writing a client directly, and the transactional-outbox pattern as a concrete worked example of that choice.

Source and sink connectors

A source connector reads from an external system and produces records onto Kafka topics (e.g. tailing a database’s binlog and publishing row changes); a sink connector consumes from Kafka topics and writes into an external system (e.g. indexing records into a search engine). Both are configuration, not code, from an operator’s point of view: a connector is installed once as a plugin (a JAR on the worker’s plugin path) and then instantiated any number of times by posting a JSON configuration to the REST API described below — no KafkaProducer/KafkaConsumer code is written or deployed for the specific integration itself.

Workers: standalone vs. distributed mode

A worker is the JVM process that actually runs connectors and their tasks. Connect ships two ways to run workers:

Mode Behavior

Standalone (connect-standalone.sh)

A single worker process; connector and task state, offsets, and configuration all live in local files on that one machine. Simple to run, but has no fault tolerance — if the process or its host goes down, everything it was running stops until it’s restarted, and there’s no coordination with any other worker.

Distributed (connect-distributed.sh)

Multiple worker processes sharing one group.id, coordinating the same way a consumer group does. Connector and task state, offsets, and configuration are stored in internal, replicated Kafka topics (connect-configs, connect-offsets, connect-status by default) instead of local files, so any worker in the group can pick up a connector’s tasks if another worker leaves, and configuration changes made via the REST API on any one worker propagate to the whole group.

Distributed mode is the practical default for anything beyond local development or a single throwaway integration — standalone mode’s lack of fault tolerance and its purely local, file-based state make it a poor fit for anything meant to run unattended in production.

Tasks and converters

A connector configuration doesn’t run directly — it is split into one or more tasks, each an independent unit of work the worker(s) schedule and execute; tasks.max caps how many a connector may be split into, and Connect itself decides how to divide the connector’s work across that many tasks (e.g. one task per table for a JDBC source connector, or a partitioned split of a topic’s partitions for a sink connector). Every task reads or writes data through a converter, configured independently for keys and values (key.converter/value.converter), which is what actually translates between a connector’s internal Struct/schema representation and the bytes that go on the wire — JsonConverter (with or without an embedded schema), StringConverter, or a schema-registry-backed Avro/Protobuf/JSON Schema converter such as io.confluent.connect.avro.AvroConverter, matching the serialization choices covered on Kafka Serialization and Schemas. A converter is a cluster-wide (or connector-level, if overridden) setting, independent of which specific connector plugin is running — the same AvroConverter configuration works whether the connector on the other end is a JDBC source or an Elasticsearch sink.

Single message transforms

A single message transform (SMT) is a small, chainable, stateless per-record modification applied by the worker between a connector and its converter — renaming or masking a field, dropping a tombstone, flattening a nested value, or routing a record to a different topic based on its contents. SMTs are configured directly on the connector, as an ordered list (transforms=addPrefix,maskSsn, each with its own transforms.<name>.type and settings), and run entirely inside the worker process with no external service call — they are for shaping individual records in flight, not general-purpose stream processing (that’s Kafka Streams' job). The outbox event router used in the worked example below is itself shipped as an SMT.

The REST API

Every Connect worker exposes a REST API (default port 8083) for managing connectors — creating, updating, pausing, resuming, restarting and deleting them, and inspecting task status — and in distributed mode this API is how configuration actually gets into the cluster, since there’s no separate admin CLI:

package com.example.connect;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class OutboxConnectorRegistrar {

    private static final String CONNECT_REST_URL = "http://localhost:8083/connectors";

    public static void main(String[] args) throws Exception {
        String connectorConfig = """
                {
                  "name": "orders-outbox-connector",
                  "config": {
                    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
                    "tasks.max": "1",
                    "database.hostname": "postgres",
                    "database.dbname": "orders",
                    "table.include.list": "public.outbox",
                    "transforms": "outbox",
                    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
                  }
                }
                """;

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(CONNECT_REST_URL))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(connectorConfig))
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println("Connector registration status: " + response.statusCode());
    }
}

GET /connectors/<name>/status reports the connector’s and each task’s state (RUNNING, PAUSED, FAILED, …​) and the worker currently running them; PUT /connectors/<name>/config updates a running connector’s configuration in place without deleting and recreating it. See Apache Kafka Documentation — Kafka Connect User Guide for the full endpoint reference.

Offsets and error handling

A source connector’s notion of "how far it has read" from the external system (a database’s binlog position, a file’s byte offset, a table’s last-seen primary key) is its source offset, stored by Connect itself (connect-offsets in distributed mode, or a local file in standalone mode) so a restarted or migrated task resumes from where it left off rather than replaying everything or skipping ahead. A sink connector’s offset is simply its consumer group’s committed Kafka offsets, using the same mechanism covered on Kafka Consumers.

errors.tolerance controls what a task does when converting or transforming a record fails: none (the default) fails the task outright on the first bad record, while all skips the bad record and continues. Paired with errors.tolerance=all, a sink connector’s dead-letter queue (errors.deadletterqueue.topic.name, plus errors.deadletterqueue.context.headers.enable=true to attach failure metadata as record headers) routes records that fail conversion or transformation to a separate Kafka topic instead of silently dropping them, so they can be inspected and, if needed, reprocessed — the same dead-letter-queue idea covered for Kafka Streams on Kafka Streams, applied here to a sink task instead of a stream processor. Source connectors have no equivalent dead-letter topic, since there is no Kafka record yet to reroute at the point a source-side read or conversion fails — errors.tolerance on a source task only governs whether such a failure is skipped or fails the task.

The transactional outbox pattern via CDC: when to use Connect instead of a producer/consumer

A service that both writes to its own database and needs to publish an event about that write faces a classic consistency problem: if it writes to the database and then calls a Kafka producer as two separate steps, a crash between them either loses the event or, with the write rolled back, publishes an event for something that never actually happened. The transactional outbox pattern solves this by writing the event, as an ordinary row, to an outbox table in the same database transaction as the business change — so the write and the event record are atomic by construction — and then relying on something else to publish that row onward to Kafka.

This is exactly where change data capture (CDC) via Kafka Connect is the right tool, and writing a producer directly is not: a CDC source connector (e.g. Debezium’s PostgreSQL or MySQL connector) tails the database’s own write-ahead/binary log for changes to the outbox table and turns each inserted row into a Kafka record, without the application ever calling a Kafka client itself. Debezium’s outbox event router SMT (io.debezium.transforms.outbox.EventRouter, configured on the connector as in the REST API example above) then reshapes that raw table-change event — deriving the destination topic from an aggregatetype column, the record key from an aggregateid column, and the record value from a payload column — so downstream consumers see a clean domain event on a topic named after its aggregate, rather than a raw "row inserted into the outbox table" change event.

Whether to reach for Connect+CDC here instead of a hand-written producer call comes down to what the service can tolerate: a direct producer call inside the same request is simpler to reason about locally but reintroduces the dual-write problem above unless it participates in the same transaction as the database write (which a plain KafkaProducer call cannot, short of the two-phase patterns Kafka Reliability and Exactly-Once Semantics discusses in a different context); the outbox-via-CDC approach adds an operational dependency (running Connect, and Debezium’s connector, as infrastructure) but gets atomicity between the business write and the eventual publish for free, at the cost of at-least-once delivery and some replication lag between the database commit and the Kafka record actually appearing.

Kafka Connect architecture diagram: a source connector’s task on Worker 1 reads from an external system and writes to a Kafka topic; a sink connector’s task on Worker 2 reads that topic and writes to a different external system; both workers register and are managed through the Connect REST API and coordinate distributed-mode state through the internal connect-offsets: connect-configs: and connect-status topics