Skip to main content
Kafka beginner Lesson 3 of 10

Producing to Kafka from Python

Send records with confluent-kafka, read the broker's acknowledgement, handle delivery failures, and see why the producer is asynchronous by default.

The console producer is fine for poking at a topic. Real producers run inside applications, and the interesting behaviour — batching, acknowledgement, failure handling — is only visible from code.

Installing the client

pip install confluent-kafka
Successfully installed confluent-kafka-2.6.1

The smallest producer that works

from confluent_kafka import Producer

producer = Producer({"bootstrap.servers": "localhost:9092"})

producer.produce("orders", key="order-1", value="espresso")
producer.flush()

print("sent")
$ python produce_one.py
sent

That works, but it tells you nothing. produce() returned immediately without contacting the broker at all — it appended the record to an in-memory queue and handed control back. The actual network send happened during flush().

If you delete the flush() line, the script still prints sent and exits — and the record never reaches Kafka. There is no error, because nothing failed; the process just ended before the background thread got to work. This is the single most common way to lose data with this client.

Seeing the acknowledgement

To learn what actually happened to a record, pass a delivery callback:

from confluent_kafka import Producer

def delivered(err, msg):
    if err is not None:
        print(f"FAILED: {err}")
    else:
        print(f"ok  topic={msg.topic()} partition={msg.partition()} offset={msg.offset()}")

producer = Producer({"bootstrap.servers": "localhost:9092"})

for drink in ["espresso", "cortado", "flat white"]:
    producer.produce("orders", key="counter", value=drink, callback=delivered)

producer.flush()
$ python produce_callbacks.py
ok  topic=orders partition=0 offset=4
ok  topic=orders partition=0 offset=5
ok  topic=orders partition=0 offset=6

Now the broker’s answer is visible: each record was assigned a partition and an offset. All three went to partition 0 because they share the key counter.

Notice all three callbacks fired at once, after the loop finished. The client runs callbacks on the thread that calls poll() or flush(), so in a tight loop they queue up until you give the client a chance to run them.

Producing continuously

A long-running producer should call poll(0) on each iteration. That serves the callback queue without blocking, so failures surface immediately rather than at shutdown.

import time
from confluent_kafka import Producer

def delivered(err, msg):
    if err:
        print(f"FAILED offset=? {err}")
    else:
        print(f"ok  partition={msg.partition()} offset={msg.offset()}")

producer = Producer({
    "bootstrap.servers": "localhost:9092",
    "acks": "all",              # wait for all in-sync replicas
    "linger.ms": 5,             # wait up to 5ms to fill a batch
    "compression.type": "zstd",
})

for i in range(5):
    producer.produce("orders", key=f"user-{i % 2}", value=f"event-{i}",
                     callback=delivered)
    producer.poll(0)            # serve callbacks, do not block
    time.sleep(0.2)

remaining = producer.flush(timeout=10)
print(f"unflushed records: {remaining}")
$ python produce_loop.py
ok  partition=0 offset=7
ok  partition=2 offset=5
ok  partition=0 offset=8
ok  partition=2 offset=6
ok  partition=0 offset=9
unflushed records: 0

The callbacks now interleave with production instead of arriving in a clump. user-0 and user-1 split across two partitions and each key stays on its own.

flush() returns the number of records still in the queue when it gave up. 0 means everything was acknowledged. A non-zero return after a timeout is your signal that records were not delivered — check it rather than assuming.

What happens when the broker is gone

Stop the broker (Ctrl+C in its terminal) and run the loop again:

$ python produce_loop.py
%3|1771059612.418|FAIL|rdkafka#producer-1| [thrd:localhost:9092/bootstrap]: localhost:9092/bootstrap: Connect to ipv4#127.0.0.1:9092 failed: Connection refused (after 0ms in state CONNECT)
FAILED offset=? Local: Message timed out
FAILED offset=? Local: Message timed out
FAILED offset=? Local: Message timed out
FAILED offset=? Local: Message timed out
FAILED offset=? Local: Message timed out
unflushed records: 0

Two useful things here. The %3|...|FAIL| line comes from librdkafka’s internal logger, not your callback — the client keeps retrying in the background and reports connection trouble as it goes. Your callback only fires once the record finally gives up, after message.timeout.ms (default 300 seconds, so this run had it lowered).

The records were not silently dropped: every one produced an error callback. A producer that ignores the err argument turns a total outage into a silent data loss, which is why the callback is the first thing to wire up, not the last.

Delivery configuration that matters

SettingDefaultWhy you would change it
acksall (3.0+)all waits for in-sync replicas; 1 is faster but loses data on leader failure
enable.idempotencetrue (3.0+)Prevents duplicates from producer retries
linger.ms0Raise to 5-100 to batch more and cut request count sharply
compression.typenonezstd or lz4 typically cuts network use by 3-5x on JSON
message.timeout.ms300000Lower it so failures surface in seconds, not minutes
retriesvery highLeave it; idempotence makes retries safe

Modern defaults are good. The two you almost always set explicitly are linger.ms (for throughput) and compression.type (for cost).

Practice

1. Produce a record to a topic that does not exist. What happens?
producer.produce("does-not-exist", value="hello", callback=delivered)
producer.flush()
ok  partition=0 offset=0

It succeeds — the broker auto-created the topic, because auto.create.topics.enable defaults to true. The topic gets the broker’s default partition count, which is usually not what you want. Production clusters normally set this to false, and then the same call fails with UNKNOWN_TOPIC_OR_PART.

2. Produce 10,000 small records with linger.ms=0, then with linger.ms=50. Time both.
import time
from confluent_kafka import Producer

def run(linger):
    p = Producer({"bootstrap.servers": "localhost:9092", "linger.ms": linger})
    start = time.perf_counter()
    for i in range(10_000):
        p.produce("bench", value=f"record-{i}")
        p.poll(0)
    p.flush()
    return time.perf_counter() - start

print(f"linger.ms=0   {run(0):.2f}s")
print(f"linger.ms=50  {run(50):.2f}s")
linger.ms=0   1.94s
linger.ms=50  0.61s

Waiting a few milliseconds lets the client pack many records into each request, so it makes far fewer round trips. You trade a tiny amount of latency for a large throughput gain — usually the right trade for anything that is not user-facing.

3. Set message.timeout.ms to 3000, stop the broker, and produce. How long until the callback fires?
producer = Producer({
    "bootstrap.servers": "localhost:9092",
    "message.timeout.ms": 3000,
})
FAILED offset=? Local: Message timed out

About three seconds. The default of 300000 means a broker outage would leave your application silently buffering for five minutes before reporting anything — long enough that most services would rather fail fast and shed load.

4. Produce the same record twice with enable.idempotence=true. Do you get one offset or two?
ok  partition=0 offset=10
ok  partition=0 offset=11

Two. Idempotence deduplicates producer retries of the same record — the client stamps each record with a producer ID and sequence number so a retried send is not written twice. It does not deduplicate two deliberate produce() calls, which are genuinely two records. Application-level deduplication needs a key and log compaction, or a downstream upsert.

Next: reading those records back from code, and how consumer groups divide the work.

Frequently Asked Questions

Should I use confluent-kafka or kafka-python?
Use confluent-kafka for production. It wraps librdkafka, the C client Confluent maintains alongside the broker, so it is faster and tracks new broker features closely. kafka-python is pure Python and easier to install, but it lags on features and throughput.
Why do I need to call flush() before my script exits?
produce() only appends to an in-memory buffer and returns immediately. If the process exits before the background thread has sent that buffer, the records are lost silently. flush() blocks until every buffered record has been acknowledged or failed.
What does acks=all actually wait for?
The leader waits until every in-sync replica has written the record to its log before acknowledging. Combined with min.insync.replicas=2 it means the record survives the loss of any single broker. With acks=1 only the leader has it, so a leader failure right after the ack loses the record.
Is the delivery callback guaranteed to run?
It runs for every record you produce, but only while you are calling poll() or flush() — the client executes callbacks on the thread that calls those methods. A script that produces in a loop without polling will queue up callbacks and run them all at flush() time.