Skip to content
About
Main content

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 . 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 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

After a kill, how many numbers does the kafka-python auto-commit consumer lose?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    auto-commit-versus-commit-after-process.py70 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    auto-commit-versus-commit-after-process.compose.yml24 linesDownload
    View 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: 40
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

10,000 records, one kill each
CommitMissingProcessed twice
auto-commit every 1 s0104
commit after every 100 processed087
Reference runApple M2 · 8 cores · 8 GB
Output · uv run auto-commit-versus-commit-after-process.py
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 twice
macOS 26.6.2 · Python 3.13.3 · kafka-python 3.0.11 · apache/kafka:3.9.1 · Docker 28.5.1 · run 2026-10-25

What 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 decision in clients that commit what they have fetched, and a silent 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.