Exactly-once processing with Kafka transactions

Kafka transactions let a read-process-write pipeline commit output and consumer offsets together, so downstream Kafka readers see each result once. Learn what the configuration covers and where it stops at external systems.

On this page

When exactly-once processing with Kafka transactions is the right guarantee

Choose transactions when a team consumes one or more topics, transforms the records and writes results to another topic, and a retry or crash would otherwise duplicate that output. A typical read-process-write service commits the input offsets and the produced records as one unit, so either both become visible or neither does. Transactions only cover writes into Kafka and the offset commits that belong to the same group, so they are the right tool when the destination is also Kafka. The Kafka 4.3 documentation describes the producer side as a transactional producer identified by a stable transactional.id, and the consumer side as consumers that read with isolation.level set to read_committed so they skip aborted or uncommitted records. Confirm the exact configuration keys and their defaults against the Kafka 4.3 security and operations documentation and the release notes for the line you run, since the supported lines today are 4.3.1, 4.2.2 and 4.1.2 according to the project downloads page.

Idempotence alone is the right choice when the risk is a producer retry duplicating a single write, because the broker deduplicates retried records from one producer session. It does not make a multi-step read-process-write pipeline atomic. If a consumer commits its offset after writing output, a crash between those two steps still produces duplicates on restart. Transactions close that gap by tying the output records and the offset commit to one transaction. Decide early whether your application can accept at-least-once delivery with idempotent, keyed writes into a store you control. That is often simpler to operate, and it is the only option when the sink is not Kafka at all.

How transactional.id and offsets inside the transaction prevent duplicates

The mechanism rests on two identities. The transactional.id is a stable name for a producer role, so that when a process restarts, the broker can recognize the new instance as the successor of the old one and fence the old one off. Each transaction then follows a fixed sequence: the producer calls initTransactions() once, calls beginTransaction(), sends its output records, calls sendOffsetsToTransaction() with the consumer group metadata (from consumer.groupMetadata()), and finally calls commitTransaction() or abortTransaction(). Because the input offsets are written into the same transaction as the output records, the consumer's progress and its results commit together or not at all. Confirm the exact method names and their behavior against the producer and consumer Javadoc for the client version you run, since the Kafka security overview does not describe the transactional API.

Consumers decide what they see. Records from aborted transactions are stored in the log but marked as aborted, and a consumer with isolation.level set to read_committed skips them and only returns records from committed transactions. The default isolation level is read_uncommitted, so a consumer that does not set this key will read output from transactions that later abort, and the duplicate-free guarantee does not hold for it. Also check that every downstream consumer of the output topic uses the same setting, because one misconfigured reader can reintroduce the problem for its own data. Plan a test that aborts a transaction on purpose and confirms the aborted records never appear to a read_committed consumer.

Configuration options and when each fits

This table compares the client and Streams settings that control duplicate prevention, transaction lifetime and zombie fencing, with the decision each one drives. Read each row as a choice you make per application, and confirm default values against the Kafka 4.3 configuration reference before you set them.

SettingDecision it drivesTrade-off to weigh
transactional.idGives a producer a stable identity so the broker can fence an older instance that is still running after a restartMust be unique per producer instance and stable across restarts; reusing one id across instances causes fencing of live producers
enable.idempotenceWhether the producer deduplicates retried sends within a session, a prerequisite for transactionsConfirm the default in the 4.3 reference; settings that conflict with idempotence can disable it or cause configuration errors
acksDurability required before a send counts as successful; transactional producers need all in-sync replicas acknowledgmentLower acknowledgment settings raise throughput but weaken what a committed transaction guarantees on broker failure
isolation.level (read_committed)Whether downstream consumers see records from aborted transactions or only committed onesRead_committed hides open and aborted records, which can add latency to consumption at the end of a transaction
transaction.timeout.msHow long a broker waits for a transaction to finish before aborting itLonger timeouts tolerate slow processing but keep open transactions and stalled consumers longer; must fit within broker limits
group.instance.idStatic membership for a consumer, so restarts do not trigger a group rebalanceOnly helps when instances have stable identities; duplicate ids cause members to fence each other
Consumer group metadata (sendOffsetsToTransaction)Commits input offsets in the same transaction as output records, so reprocessing is atomicRequires passing the current group metadata object, not just the group id; the consumer must also use read_committed upstream
exactly_once_v2 (Kafka Streams)Lets Streams apps get exactly-once semantics without wiring transactions by handApplies only to Streams topologies; confirm broker and client version support in the 4.3 documentation before enabling

How zombie fencing stops an old instance from committing

A zombie is a producer instance that the rest of the system has given up on but that is still able to send. Kafka prevents it from writing by tying each transactional.id to a producer epoch that only the newest instance holds, so the cards below follow the path from identity to the restart behavior your application needs.

Identity comes from transactional.id

Two producers that share a transactional.id are treated as the same logical producer, and Kafka tracks that identity across restarts. The ID must be stable for a given unit of work (for example, one input partition assignment) and unique across live instances. Check that your deployment does not reuse one ID for several replicas, because that makes fencing fire on healthy instances. Confirm how your orchestrator names pods or workers before you assign IDs.

A newer epoch fences the older one

When a producer calls initTransactions() with an ID already in use, the coordinator bumps the epoch and the previous holder is no longer valid. Any later write, commit or offset send from the old instance is rejected, even if the process is still running and believes it is healthy. This is the mechanism that stops a paused or network-partitioned instance from completing a transaction after its replacement has started.

Fencing errors mean stop

A fenced producer raises a fatal error, typically a ProducerFencedException, and must not retry the transaction it was in. Retrying would only repeat a write the cluster has already refused, and it can hide the fact that another instance now owns the work. Treat the error as a signal to abort local state, close the producer and let the new owner resume from committed offsets. Test this path on purpose in staging.

Timeouts can trigger fencing

A long garbage collection pause, a slow disk or a stalled network link can make a producer miss its transaction timeout or its session with the coordinator. The broker then aborts the open transaction and the next attempt from that instance fails. Set transaction.timeout.ms to cover your worst-case batch processing time, and alert on aborted transactions so pauses show up before they cause duplicate or lost work.

Rebalances hand work to a new owner

In a consumer group that runs read-process-write, a rebalance moves partitions to other members, and each member should initialize its own transactional ID for the partitions it now owns. The previous owner's in-flight transaction must not commit. Make sure the consumer's rebalance listener stops processing and abandons the open transaction before the partitions are released, rather than finishing work on partitions it no longer holds.

Restarts start a clean transaction

After a crash or restart, the instance should call initTransactions() before producing anything, which aborts any dangling transaction left by its predecessor and fences that predecessor's epoch. Your code should not rebuild state from memory of the old transaction. Verify that a restarted instance reads offsets from the last committed transaction and that downstream read_committed consumers never see the aborted writes.

Setting up a transactional read-process-write pipeline

This sequence takes a consume-process-produce service from version checks to a tested pipeline where each output batch and its input offsets commit together or not at all. The result is a set of stable transactional producers, read_committed downstream consumers and evidence that failures do not produce duplicates.

  1. Confirm client and broker versions

    Before writing code, the platform owner and the application team check the Kafka version of every broker and client against the upgrade notes for the release in use. Brokers on Kafka 4.x need Java 17, while clients and Kafka Streams applications need at least Java 11. Confirm that the brokers run in KRaft mode, because Kafka 4.0 removed ZooKeeper mode and clusters must already be on KRaft before upgrading to 4.x. Check the supported releases on the downloads page so the platform is not on an archived version. The step is done when a written version matrix lists every broker, client library and runtime, and each entry is confirmed against the notes for the release you plan to run.

  2. Create topics with planned partitions

    The platform team creates the input, output and any state topics with partition counts and replication factors agreed in the design. Partition count matters here because it sets how many transactional producers and consumer instances can work in parallel, and changing it later can alter key-to-partition mapping for keyed data. Set the replication factor and minimum in-sync replicas so that a broker failure does not stop writes or lose committed transactions. Document the topic configuration in version control. The step is done when kafka-topics output or the infrastructure definition shows each topic with the agreed partitions, replication factor and settings, and a test produce succeeds.

  3. Assign stable transactional IDs per instance

    Each running instance of the processing service gets a transactional.id that stays the same across restarts but differs between instances that run at the same time. The application team derives the ID from a stable value such as the consumer group, the input partition range or a StatefulSet ordinal, and never from a random value or process ID. Stable IDs let the broker fence an older instance when a new one takes over, which is how zombie producers are blocked. Record the naming scheme in the service documentation. The step is done when a restart of one instance reuses its ID, two running instances report different IDs in logs, and the scheme is written down.

  4. Implement the consume-process-produce loop

    The development team writes the loop so that it calls initTransactions once at startup, then for each batch begins a transaction, produces the output records, calls sendOffsetsToTransaction with the consumed offsets, and commits. Auto-commit is turned off on the consumer, and the consumer group metadata is passed with the offsets so the broker can verify the group. On any exception the code aborts the transaction and does not commit offsets separately. Keep the processing step free of side effects that cannot be undone, because an aborted transaction discards produced records but not external writes. The step is done when unit tests show that an exception after produce leads to an abort, and the committed offsets match the last successful batch.

  5. Set read_committed on downstream consumers

    Every consumer that reads the output topics sets isolation.level to read_committed, so it skips records from aborted transactions and waits for transaction markers. Downstream teams confirm this setting in their own client configuration, since the default of read_uncommitted lets them see records that are later aborted. Expect some added latency on these consumers, because records become visible only after their transaction commits. The step is done when a consumer test reads a topic containing one committed and one aborted transaction and receives only the committed records.

  6. Verify with injected failures

    The QA or platform team runs a test plan that deliberately kills instances mid-batch, forces a broker leader change, and starts a second instance with the same transactional.id while the first is still running. For each case, they compare the output topic against the input with a deterministic checker that counts each logical record once. They also confirm that the older instance's writes are rejected after fencing. Keep the test results and logs with the release record. The step is done when every injected failure produces no duplicate committed output, no gaps in committed input offsets, and a fenced instance that cannot commit.

  7. Confirm the limits with external systems

    The team lists every write the pipeline makes outside Kafka, such as a database update, an HTTP call or an email, and decides how each is made idempotent or reconciled. Kafka transactions cover only Kafka topics and consumer offsets, so an external write done inside the loop can repeat after a failure. Common approaches include storing the output key with a unique constraint or writing offsets and results in the same external database transaction. Ask the vendor or the project documentation to confirm any specific connector or sink behavior before relying on it. The step is done when each external side effect has a documented deduplication or reconciliation method and a test that shows a replayed batch does not change the external state twice.

Security and access controls for transactional clients

A transactional producer touches more resources than a plain producer, and each one needs an explicit grant. The cards below cover the authorization grants, transport security and identifier design that decide whether a transactional client can run at all and whether it can be fenced safely.

Grant access to transactional.id

A transactional producer must hold the transactional id permission on the transactional.id value it uses, in addition to write access to the topics it produces to. Under the StandardAuthorizer, these ACLs live in the cluster metadata log, so they are created and reviewed with the same tooling as other ACLs. Check that each producer principal has a grant covering exactly its own transactional id, not a broad prefix that lets it claim another application's identity.

Grant group permissions for offset commits

Sending offsets inside a transaction commits consumer group progress, so the producer principal also needs access to the consumer group it commits for. Without that group permission, the commit step fails and the transaction cannot complete. Confirm the group ACL matches the group name the application actually uses, since a mismatch tends to surface only at runtime under load.

Deny by default with no-ACL behavior

In KRaft clusters, resources without ACLs are restricted to super users unless allow.everyone.if.no.acl.found is set to true. Keep that setting off for clusters that run transactional workloads, so a newly created transactional topic or group is not open by accident. Test a new transactional client against a cluster where it holds only the grants it needs before promoting it.

Encrypt and authenticate client connections

Kafka authenticates clients with SSL or SASL, and the documented SASL mechanisms include GSSAPI, PLAIN, SCRAM-SHA-256, SCRAM-SHA-512 and OAUTHBEARER. Security is optional in Kafka, so a listener can be left unsecured or mixed with secured listeners. Transactional producers should connect only to listeners that enforce TLS and an authenticated principal, because the authenticated identity is what the ACLs evaluate.

Avoid shared transactional.id values

Each running producer instance must use a transactional id that no other live instance uses, because the broker fences the older instance when a newer one initializes with the same id. Two replicas sharing an id will keep fencing each other, and a misconfigured deployment can silently stall one of them. Derive ids from a stable instance identity, and make sure ACLs and fencing expectations both reflect that one-id-per-instance model.

Plan authorization with external services

Kafka authorization is pluggable and supports integration with external authorization services. If your organization manages access centrally, decide early whether ACLs are maintained in the cluster or delegated, and whether transactional grants can be expressed in that system. Confirm the chosen authorizer supports the transactional and group operations before you rely on it for production producers.

Where exactly-once stops: external systems and what to confirm

Do Kafka transactions cover writes to a database or an HTTP API?

No. Kafka transactions only commit records written to Kafka topics and consumer offsets stored in Kafka. A write to a database table, a call to a payment API or an email send happens outside the transaction, so Kafka cannot roll it back or fence it. The end-to-end result is exactly-once only when the external effect is itself idempotent or transactional. Treat every side effect as a separate design decision and list each one in the pipeline design before build starts.

Does the sink support idempotent writes or transactions?

Confirm this with the sink's documentation before relying on any pattern. The usual options are an idempotent upsert keyed on a stable record identifier, a transaction in the same database that also stores the processed offset, or a deduplication table with a unique constraint. If the sink offers none of these, expect at-least-once delivery and plan deduplication downstream. Ask the database or service vendor for the exact guarantee their client library provides, including behavior on retries.

How do you store offsets outside Kafka for an external sink?

Store the consumer position in the same transactional store as the external write. Each processed record then updates the result row and the offset row in one database transaction, and on restart the consumer seeks to the stored offset instead of the committed group offset. This pattern requires assigning partitions explicitly or handling rebalances so each partition has one writer. Confirm the offset table schema, the recovery procedure and who owns it, because mistakes here cause silent loss or duplicates.

What happens if the consumer crashes between the commit and the external write?

It depends on the order of operations. If the consumer commits its offset to Kafka before the external write completes, a crash can lose that record's effect entirely (at-most-once). If it writes externally first and commits afterward, a crash between the two steps replays the record (at-least-once), so the external write must tolerate duplicates. Illustrative test: kill the consumer after the database insert and before the offset commit, restart it, and count the rows. Run this test in staging before go-live.

Can a zombie instance still write to the external system after fencing?

Kafka fencing protects only the Kafka side. When a newer producer instance with the same transactional.id starts, the broker rejects writes from the older instance, but a stale process can still reach a database or API that does not check Kafka epochs. Protect external writes with a fencing token or a version column checked in the sink, and confirm that the application validates it. Without that check, a paused process that resumes after a rebalance can overwrite newer state.

Which Kafka version behavior does the team depend on?

Record the exact Kafka release, client library version and the processing guarantee setting in the design document. Kafka 4.x supports only KRaft mode, and rolling upgrades are supported from each release in the past year to the latest version, so check the upgrade notes for the target release before changing transaction or consumer settings. Kafka Streams applications have their own exactly-once configuration, and older clients may behave differently. Confirm behavior against the release notes for each version in use, not against memory.

Can DeployOpen design and test the external write path for us?

DeployOpen plans, implements, integrates and tests self-hosted open-source platforms inside your own infrastructure, and the external write path is part of that scope when the engagement is written that way. A fixed-scope engagement would list the sink, the offset store, the failure tests and the deliverables before work starts. Your team keeps ownership of the business rules for deduplication and of the sink's own service levels, which are outside Kafka. A quote follows from that written scope.

What does this work cost, and what drives the cost?

Cost depends on the number of external sinks, whether each one supports idempotent or transactional writes, how much offset and deduplication logic must be built, and how many failure scenarios need repeatable tests. Kafka itself is open-source under the Apache License 2.0, so the license fee is zero, but infrastructure, staff time and any sink licensing remain. Ask for a quote that lists each sink, its write pattern, the failure tests and the recurring operational tasks such as monitoring and version upgrades.

Pairing Kafka transactions with idempotent external sinks

Kafka transactions stop duplicates inside Kafka, but most pipelines write results to a database, search index or API, and that write sits outside the transaction. To bring the end-to-end effect closer to once, the sink itself has to tolerate replays. Three patterns do most of the work. The first is an idempotent upsert keyed by record identity: derive a stable key from the source topic, partition and offset, or from a business identifier carried in the record, and write with an upsert so a second delivery overwrites the same row instead of adding one. The second is a transactional outbox, where the service writes its state change and an outbox row in one local database transaction, and a relay publishes the outbox rows to Kafka. The third is storing consumer offsets in the same database transaction as the results, so the committed position and the output either both persist or both roll back.

Each pattern is a design choice that the team must verify in its own sink, and none of them comes from Kafka alone. Check that the key is unique and stable across retries, that the upsert logic cannot be bypassed by a second code path, and that the offset table is read on startup before any consumption resumes. Confirm how your database handles isolation and conflicting writes under concurrent consumers, and whether a relay restart can publish an outbox row twice. Test the sink by replaying a known batch of records and by killing the process between the database commit and the Kafka commit. Note that a fenced producer from a zombie instance can still reach an external database unless the sink enforces its own ownership check. Treat the output of these tests, not the pattern name, as the evidence for your design review.

What exactly-once with Kafka transactions costs and requires

What drives the effort of an exactly-once Kafka transactions build?

The effort is driven mainly by the shape of the processing topology, the number of sinks the transaction must reach, and how deeply the team tests failure paths. A single consume-transform-produce loop inside Kafka is far less work than a flow that reads from several topics, writes to Kafka and to an external database, and must survive broker failover and consumer restarts. Each additional sink adds its own idempotency or deduplication design, its own failure cases and its own verification. Testing depth is often the largest variable: fencing, aborted transactions and rebalances during a commit all need deliberate tests against a real cluster. Ask any proposal to break the work down by topology, sink and test scenario so the drivers are visible.

Can our own team build exactly-once processing without a partner?

Yes, if the team already runs Kafka in production and has engineers who understand producer epochs, transactional IDs and consumer isolation levels. The Kafka project publishes its security, upgrade and client documentation openly, so the knowledge is available without a license. The risk is usually not the first working version but the edge cases: a zombie instance that keeps writing after a restart, a sink that is not idempotent, or a consumer that reads uncommitted data because it was not set to read_committed. A partner is most useful when the team lacks this experience, when the timeline is tight, or when independent testing is needed before production. Assess your team's skills against the specific topology before deciding.

What do we need to provide for an exactly-once engagement?

Plan to provide access to the Kafka cluster in your own infrastructure, including the broker version, listener and authentication settings, and the ACLs needed to create transactional producers and read the relevant topics. You also need people who know each sink system, such as the database schema, its unique keys, and how it handles duplicate writes. A test environment that mirrors production topology is important, because transaction behavior under failover cannot be checked on a single laptop broker. Finally, provide the expected message volumes, the failure scenarios the business accepts, and named owners who can make decisions during testing. Confirm each item in writing before the scope is fixed.

How long does an exactly-once transactions project take?

Duration depends on the scope agreed in writing, not on a standard schedule. The main inputs are the number of processing flows, the number and type of sinks, whether existing consumers must change, and how much fault-injection testing is required before production. Work is typically planned in phases: design of transaction boundaries and offset handling, implementation, a test campaign against failure modes, and a controlled rollout. Each phase depends on decisions from your team, such as access approvals and sink owners being available. Ask for a timeline that lists these dependencies explicitly, so delays from your side are visible.

What recurring work follows go-live for transactional Kafka processing?

After go-live, recurring work includes upgrading Kafka within supported releases, retesting transactional flows after each upgrade, and monitoring the system for fenced or stalled instances. The Kafka time-based release plan says rolling upgrades are supported from each release in the past year to the latest version, so upgrades are planned rather than optional. Operators should watch transaction timeouts, aborted transaction rates, consumer lag on read_committed consumers, and sink errors. Application changes such as new sinks or new topic partitioning also need a transaction review. Budget for this ongoing work in the operating model, whether run by your team or under a support arrangement.

Does exactly-once cover writes to databases and other external systems?

No. Kafka transactions guarantee atomic writes to Kafka topics and atomic commits of consumer offsets within Kafka, but they do not coordinate with external systems such as relational databases, search indexes or HTTP APIs. For those sinks, the usual approach is an idempotent write keyed on a unique identifier, or a transactional outbox pattern that moves the external write into Kafka's transaction. Each option has a cost in schema changes, deduplication storage and operational complexity. Confirm with the sink owner how duplicate writes are detected before accepting any end-to-end guarantee.

Does DeployOpen provide exactly-once work as a fixed scope or a retainer?

DeployOpen usually agrees exactly-once work as a fixed scope, with scope, deliverables and timelines set in writing before work starts. Ongoing support, upgrades and continuous testing are available as retainers, which suits teams that want help with each Kafka upgrade and with retesting transactional flows over time. Pricing follows from the written scope, so the first step is a scoping conversation covering topology, sinks, test depth and your environment. DeployOpen prepares the system and evidence but does not issue audit reports or certifications.

How DeployOpen scopes and delivers exactly-once Kafka pipelines

DeployOpen plans, implements, hardens, documents and supports self-hosted Kafka inside your own cloud accounts, data centers or air-gapped networks. For exactly-once processing with Kafka transactions, we start from the processing flow itself, then build and hand over the transactional settings, consumer behavior and operating evidence your teams need.

How we scope

  • We map every read-process-write path and mark which sinks are Kafka topics and which are external databases or APIs.
  • The written scope names transactional.id conventions, consumer isolation settings and the offset commit pattern for each pipeline.
  • Engagements are fixed-scope, with deliverables and timelines agreed in writing before any build work starts.

How we build

  • We configure transactional producers, offset commits inside transactions and read_committed consumers in your client code.
  • We test zombie fencing by forcing producer restarts and confirming that stale instances are rejected by the broker.
  • We document where exactly-once stops at external systems and design idempotent writes or deduplication for those boundaries.

How we hand over

  • You receive the transaction design, configuration reference, test results and a runbook for diagnosing aborted or hanging transactions.
  • We walk your engineers through the code paths, failure scenarios and the checks to repeat after client library upgrades.
  • Documentation covers the Kafka version and client library versions tested, so your team knows what to revalidate later.

How we support

  • Ongoing support, upgrades and continuous testing are available as retainers after handover.
  • Retainers can cover revalidation of transactional pipelines when you upgrade Kafka or client libraries.
  • Ask any partner to confirm in writing which parts of the pipeline and which external systems fall inside the support scope.

Sources and further reading

Talk to our team.

Tell us what you're working on, whether it's a deployment, an audit, a security test or a cyber range. You'll speak with an engineer who can help you scope it.

  • 30-minute call: free, with no obligation.
  • NDA on request: we can sign before you share details.
  • Clear next steps: a scope and plan after the call.