Auto-commit versus commit-after-process
Run a real systemBreak it on purposeNeeds Docker, Python with kafka-python
Problem
A Kafka consumer records its progress by committing offsets. With auto-commit, the client commits on a timer, whatever the application has done with the records it fetched. If the process dies after a commit but before processing, those records are skipped, and nothing reports an error. Committing manually after processing turns that into duplicates instead: up to one commit interval of records is processed again after a crash.
Use a local Kafka (or Redpanda) broker and any client library (this sketch uses kafka-python). Produce 10,000 numbered records to a topic with one partition. Write a consumer that "processes" each record by appending its number to a file after a 5 ms sleep. Run it twice: once with enable_auto_commit=True, auto_commit_interval_ms=1000, once with auto-commit off and a manual commit() after every 100 records. In both runs, kill the consumer with kill -9 at a random moment, restart it, and let it finish. Then count: which numbers are missing from the file, and which appear twice?
The question. After one kill each, how many numbers does auto-commit lose, and how many does commit-after-process duplicate?
Predict
With auto-commit, after a kill some numbers are missing (fetched, committed by the timer, never processed) and no error is reported. With commit-after-process, no number is missing, but up to about 100 numbers appear twice (your commit interval is your duplicate blast radius).
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
auto-commit-versus-commit-after-process.py # /// script # requires-python = ">=3.10" # dependencies = ["kafka-python"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Auto-commit against commit-after-process. 10,000 numbered records in a one-partition topic. A consumer "processes" each by appending its number to a file after a 5 ms sleep. Once with auto-commit every second, once with a manual commit after every 100 records. Each run is killed with SIGKILL at a random moment, restarted, and finished; then the file is checked for missing and duplicated numbers. Needs the Kafka service from the compose file (port 59092). """ import argparse, collections, json, os, random, signal, subprocess, sys, tempfile, time, uuid p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--bootstrap", default="localhost:59092", help="Kafka bootstrap server.") p.add_argument("--records", type=int, default=10_000, help="Records.") p.add_argument("--kill-between", type=lambda s: [float(v) for v in s.split(",")], default=[10, 30], help="Kill the consumer after a random number of seconds in this range.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--worker", help=argparse.SUPPRESS) p.add_argument("--topic", help=argparse.SUPPRESS) p.add_argument("--out", help=argparse.SUPPRESS) p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() if args.worker: # the consumer process from kafka import KafkaConsumer auto = args.worker == "auto" c = KafkaConsumer(args.topic, bootstrap_servers=args.bootstrap, group_id="lab-" + args.topic, enable_auto_commit=auto, auto_commit_interval_ms=1000, auto_offset_reset="earliest", max_poll_records=100, session_timeout_ms=6000, heartbeat_interval_ms=1000, consumer_timeout_ms=30000) # waits out the dead member's session before giving up out = open(args.out, "a") for msg in c: time.sleep(0.005) out.write(msg.value.decode() + "\n"); out.flush() if not auto and msg.offset % 100 == 99: c.commit() if not auto: c.commit() sys.exit(0) from kafka import KafkaProducer from kafka.admin import KafkaAdminClient, NewTopic random.seed(args.seed) admin = KafkaAdminClient(bootstrap_servers=args.bootstrap) rows = [] for mode, label in (("auto", "auto-commit every 1 s"), ("manual", "commit after every 100 processed")): topic = f"numbers-{uuid.uuid4().hex[:8]}" admin.create_topics([NewTopic(topic, 1, 1)]); time.sleep(1) pr = KafkaProducer(bootstrap_servers=args.bootstrap) for i in range(args.records): pr.send(topic, str(i).encode()) pr.flush() out = os.path.join(tempfile.mkdtemp(), "processed.txt") cmd = [sys.executable, __file__, "--worker", mode, "--topic", topic, "--out", out, "--bootstrap", args.bootstrap] proc = subprocess.Popen(cmd); time.sleep(random.uniform(*args.kill_between)) os.kill(proc.pid, signal.SIGKILL); proc.wait() subprocess.run(cmd, check=True) # restart and finish nums = [int(x) for x in open(out).read().split()] cnt = collections.Counter(nums) missing = args.records - len(cnt); dups = sum(v - 1 for v in cnt.values() if v > 1) rows.append({"commit": label, "missing": missing, "duplicated": dups}) print(f"{label:<34} killed once: {missing:>4} numbers missing, {dups:>4} processed twice") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"runs": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
auto-commit-versus-commit-after-process.compose.yml # A single Kafka broker (KRaft, no ZooKeeper). Clients connect on localhost:59092; # the PROXY listener (advertised as localhost:59093) is for labs that put a proxy in front of the broker. services: kafka: image: apache/kafka:3.9.1 ports: ["59092:9092", "59094:9094"] environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: broker,controller KAFKA_LISTENERS: PLAINTEXT://:9092,PROXY://:9094,CONTROLLER://:9093 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:59092,PROXY://localhost:59093 KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PROXY:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 healthcheck: test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1 || exit 1"] interval: 3s retries: 404Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f auto-commit-versus-commit-after-process.compose.yml up -d --wait uv run auto-commit-versus-commit-after-process.py uv run auto-commit-versus-commit-after-process.py --help # every knob docker compose -f auto-commit-versus-commit-after-process.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --bootstraplocalhost:59092 | Kafka bootstrap server. |
| --records10,000 | Records. |
| --kill-between10, 30 | Kill the consumer after a random number of seconds in this range. |
| --seed0 | Random seed. |
Our run
10,000 records, one kill each
| Commit | Missing | Processed twice |
|---|---|---|
| auto-commit every 1 s | 0 | 104 |
| commit after every 100 processed | 0 | 87 |
auto-commit every 1 s killed once: 0 numbers missing, 104 processed twice
commit after every 100 processed killed once: 0 numbers missing, 87 processed twiceWhat we found
With auto-commit, 0 numbers were missing and 104 were processed twice. With a commit after every 100 processed records, 0 were missing and 87 processed twice, within the commit interval. Neither run reported an error.
The auto-commit run did not lose records, unlike the expectation. kafka-python's consumer commits on the timer only offsets it has already handed to the application, and only at the next poll, so for a consumer that processes each record before asking for the next, it acts like commit-after-process on a one-second clock: it duplicates up to a second of work instead of losing it. Clients that commit what they have fetched rather than what they have handed out, or applications that hand records to another thread, do lose records this way. What auto-commit guarantees depends on the client, which is exactly why it should not be trusted by default.
Read it
Auto-commit is a silent at-most-once decision in clients that commit what they have fetched, and a silent at-least-once one in clients that commit what they have handed out. Either way it is a decision made by a library default, and its failure mode is invisible: no error, only missing or repeated records.
Commit after processing and size the commit interval as a duplicate budget: everything since the last commit will be processed again after a crash, so the sink must absorb that many repeats. That is the next lab.