Skip to content
About
Main content

Choose N by measurement

Run a real systemMeasure your machineNeeds Docker, Python

Problem

The count of a caps how many can work in parallel, and it is to change later without breaking per-key order. So it should be chosen from a measurement: how many records per second one consumer handles, how many the topic must sustain, and some headroom. More partitions are not free either: a takes longer to rebalance, and a sink that writes one file per partition per batch writes more, smaller files.

Measure how many records per second one consumer of your real or simulated workload handles (per_consumer_throughput). Compute N ≥ target_throughput / per_consumer_throughput, multiply by 2–3, and round up to a . Then create topics with that N and with 2×N partitions and measure: end-to-end , the time for a consumer-group rebalance when you kill one consumer, and the number of files produced per hour by a downstream sink that writes one file per partition per batch.

The script simulates 200 µs of work per record, targets 20,000 records per second, and kills one of three consumers for the rebalance test.

The question. What partition count does the measurement give, and what do N and 2N partitions cost in recovery time and in files?


Predict

Doubling from N to 2N partitions: how does the time to recover from a killed consumer change?

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.

    choose-n-by-measurement.py90 linesDownload
    View the code
    choose-n-by-measurement.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["confluent-kafka"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Choose the partition count N by measurement.
    
    Measure what one consumer of a simulated workload handles per second,
    compute N >= target / per-consumer throughput, multiply by 2-3 and round up
    to a highly composite number. Then compare topics with N and 2N partitions:
    the time for the consumer group to recover after one consumer is killed, and
    the files per hour a sink writes (one file per partition per batch).
    Needs the Kafka service from the compose file (port 59092).
    """
    import argparse, json, math, multiprocessing as mp, os, signal, time, uuid
    from confluent_kafka import Consumer, Producer
    from confluent_kafka.admin import AdminClient, NewTopic
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--bootstrap", default="localhost:59092", help="Kafka bootstrap server.")
    p.add_argument("--target", type=float, default=20_000, help="Target throughput, records per second.")
    p.add_argument("--work-us", type=float, default=200, help="Simulated processing time per record, microseconds.")
    p.add_argument("--records", type=int, default=50_000, help="Records for the throughput measurement.")
    p.add_argument("--consumers", type=int, default=3, help="Consumers in the group for the rebalance test.")
    p.add_argument("--sink-batch-min", type=float, default=5, help="Minutes per sink batch (one file per partition per batch).")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    admin = AdminClient({"bootstrap.servers": args.bootstrap})
    HCN = [1, 2, 4, 6, 12, 24, 36, 48, 60, 120, 180, 240, 360, 720]
    
    
    def topic(parts):
        name = f"n-{uuid.uuid4().hex[:8]}"; admin.create_topics([NewTopic(name, parts, 1)])[name].result(); time.sleep(1); return name
    
    def fill(name, n):
        pr = Producer({"bootstrap.servers": args.bootstrap, "linger.ms": 20})
        for i in range(n): pr.produce(name, key=str(i), value=b"x" * 200); pr.poll(0)
        pr.flush()
    
    def member(name, group, q):
        c = Consumer({"bootstrap.servers": args.bootstrap, "group.id": group, "auto.offset.reset": "earliest",
                      "session.timeout.ms": 6000, "heartbeat.interval.ms": 1000})
        c.subscribe([name], on_assign=lambda c_, parts: q.put((os.getpid(), time.time(), len(parts))))
        while True: c.poll(0.2)
    
    
    if __name__ == "__main__":
        # 1. per-consumer throughput, with the simulated work
        t1 = topic(1); fill(t1, args.records)
        c = Consumer({"bootstrap.servers": args.bootstrap, "group.id": uuid.uuid4().hex, "auto.offset.reset": "earliest"})
        c.subscribe([t1]); n, start = 0, None
        while n < args.records:
            m = c.poll(1.0)
            if m is None or m.error(): continue
            start = start or time.perf_counter()
            end = time.perf_counter() + args.work_us / 1e6
            while time.perf_counter() < end: pass                 # the work, as a busy wait
            n += 1
        per = n / (time.perf_counter() - start); c.close()
        need = math.ceil(args.target / per)
        N = next(h for h in HCN if h >= 2.5 * need)
        print(f"one consumer: {per:,.0f} records/s; target {args.target:,.0f}/s needs N >= {need}; x2.5 and rounded up to a highly composite number: N = {N}")
    
        # 2. rebalance time after killing one consumer, N and 2N partitions
        rows = []
        for parts in (N, 2 * N):
            name = topic(parts); group = uuid.uuid4().hex; q = mp.Queue()
            procs = [mp.Process(target=member, args=(name, group, q), daemon=True) for _ in range(args.consumers)]
            for x in procs: x.start()
            time.sleep(12)
            while not q.empty(): q.get()
            killed_at = time.time(); os.kill(procs[0].pid, signal.SIGKILL)
            survivors = {x.pid for x in procs[1:]}; got = {}
            while set(got) != survivors and time.time() - killed_at < 60:
                try: pid, t, k = q.get(timeout=1)
                except Exception: continue
                if pid in survivors: got[pid] = t
            recovery = max(got.values()) - killed_at if got else float("nan")
            files = parts * 60 / args.sink_batch_min
            rows.append({"partitions": parts, "recovery_s": recovery, "files_per_hour": files,
                         "max_consumers": parts, "mb_per_file": args.target * 200 * args.sink_batch_min * 60 / parts / 1e6})
            print(f"{parts:>4} partitions: group back to work {recovery:4.1f} s after a consumer was killed; "
                  f"sink writes {files:,.0f} files/hour of {rows[-1]['mb_per_file']:.1f} MB each; up to {parts} consumers")
            for x in procs[1:]: x.kill()
    
        if args.json:
            with open(args.json, "w") as f:
                json.dump({"tables": {"partitions": rows, "summary": [{"per_consumer": per, "need": need, "N": N}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    choose-n-by-measurement.compose.yml24 linesDownload
    View the code
    choose-n-by-measurement.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 choose-n-by-measurement.compose.yml up -d --wait
    uv run choose-n-by-measurement.py
    uv run choose-n-by-measurement.py --help     # every knob
    docker compose -f choose-n-by-measurement.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--bootstraplocalhost:59092 Kafka bootstrap server.
--target20,000 Target throughput, records per second.
--work-us200 Simulated processing time per record, microseconds.
--records50,000 Records for the throughput measurement.
--consumers3 Consumers in the group for the rebalance test.
--sink-batch-min5 Minutes per sink batch (one file per partition per batch).

Our run

N and 2N partitions

N and 2N partitions
PartitionsBack to work after a killFiles / hourMB per fileMax consumers
245.45 s2885024
485.48 s5762548
Reference runApple M2 · 8 cores · 8 GB
Output · uv run choose-n-by-measurement.py
one consumer: 4,963 records/s; target 20,000/s needs N >= 5; x2.5 and rounded up to a highly composite number: N = 24
  24 partitions: group back to work  5.5 s after a consumer was killed; sink writes 288 files/hour of 50.0 MB each; up to 24 consumers
  48 partitions: group back to work  5.5 s after a consumer was killed; sink writes 576 files/hour of 25.0 MB each; up to 48 consumers
macOS 26.6.2 · Python 3.13.3 · confluent-kafka 2.16.0 · apache/kafka:3.9.1 · Docker 28.5.1 · run 2026-10-20

What we found

One consumer handled 4,960 records per second, so 20,000 per second needs at least 5 partitions; with headroom and rounding up, N = 24. Doubling to 2N doubled the parallelism headroom and the sink's files (from 288 to 576 per hour) and halved their size. The rebalance did not get longer: the group was back to work 5.45 s after a kill with N and 5.48 s with 2N, because a killed consumer is only noticed when its session times out (6 s here), and that wait dwarfs the time to reassign a few dozen partitions.


Read it

Choose N from a measured per-consumer rate and a target, with two to three times headroom, and round to a number with many divisors so it splits evenly among 2, 3, 4, 6 or 12 consumers. The cost of too many partitions shows up downstream, as small files and metadata, and in with thousands of partitions; the cost of too few shows up the day you need more consumers than partitions.