How to Build an AWS Kinesis Streaming Sink to Parquet

The AWS Kinesis streaming pattern - events in, columnar files out - is the same shape whether you run Kinesis Firehose or open-source Kafka. Consume a topic, buffer events by date, flush every 100 messages to snappy-compressed Parquet, and commit offsets only after the write lands on disk so a crash never loses data. Here is how to build that sink in Python.

Data Engineerkafkaparquetpyarrow

The streaming-to-lakehouse pattern

An AWS Kinesis streaming pipeline that lands raw events in a data lake does one job: read a stream, batch the records, and write them as columnar Parquet files a query engine can scan. Kinesis Firehose is the managed version of this; a Kafka consumer writing Parquet is the open-source version. The logic is identical, so building it by hand teaches you exactly what Firehose or a Kafka Connect S3 Sink does under the hood.

In this project a Kafka topic carries clickstream events, each a JSON object like {"event_type": "click", "user_id": 42, "ts": "2024-01-15T09:31:00"}. The goal is to archive them to lake/events/date=YYYY-MM-DD/ so analysts can query with DuckDB, Spark, or Athena. Two rules make it production-grade: batch by date, and never lose an event.

Consume the topic with auto-commit OFF

The delivery guarantee lives in one setting. With enable_auto_commit=False you decide when an offset is marked done - and you only mark it done after the Parquet file is safely on disk. That is at-least-once delivery: a crash mid-batch re-reads a few events but drops none.

import json, os
from collections import defaultdict
from kafka import KafkaConsumer
import pyarrow as pa
import pyarrow.parquet as pq

BROKER = os.environ["KAFKA_BROKER"]
TOPIC = f"{os.environ['USER_ID']}-events"
BATCH_SIZE = 100

consumer = KafkaConsumer(
    TOPIC,
    bootstrap_servers=BROKER,
    group_id=f"{os.environ['USER_ID']}-parquet-sink",
    auto_offset_reset="earliest",
    enable_auto_commit=False,      # commit manually, after the write
    value_deserializer=lambda b: json.loads(b),
    consumer_timeout_ms=5000,      # iterator exits when the topic drains
)

consumer_timeout_ms=5000 is what lets the sink finish: when no message arrives for five seconds the for msg in consumer loop ends instead of blocking forever.

Buffer by date and flush to Parquet

Group events into an in-memory dict keyed by the date derived from each ts, then flush each date's list to its own Parquet file. pyarrow.Table.from_pylist builds a table straight from a list of dicts, and write_table(..., compression="snappy") handles the columnar encoding.

buffers = defaultdict(list)
last_offset = 0

def flush(date_str, events, end_offset):
    out_dir = f"lake/events/date={date_str}"
    os.makedirs(out_dir, exist_ok=True)
    out_path = f"{out_dir}/part-{end_offset:010d}.parquet"
    table = pa.Table.from_pylist(events)
    pq.write_table(table, out_path, compression="snappy")

for msg in consumer:
    last_offset = msg.offset
    date_str = msg.value["ts"][:10]      # "2024-01-15"
    buffers[date_str].append(msg.value)
    if sum(len(v) for v in buffers.values()) >= BATCH_SIZE:
        for d, evs in list(buffers.items()):
            if evs:
                flush(d, evs, last_offset)
                buffers[d] = []
        consumer.commit()               # commit AFTER the writes

Putting the offset in the filename (part-0000000149.parquet) keeps successive flushes from colliding when the same date appears in more than one batch.

Drain the tail and commit

When the loop exits on the timeout, some events are still buffered. Flush whatever remains, then commit one last time:

for d, evs in buffers.items():
    if evs:
        flush(d, evs, last_offset)
consumer.commit()
consumer.close()

Read it back to confirm every event landed and the partitions are correct:

python3 -c "import duckdb; print(duckdb.execute(\"SELECT date, count(*) FROM read_parquet('lake/events/**/*.parquet', hive_partitioning=1) GROUP BY date\").fetchall())"

That is the whole streaming sink: a topic in, three date-partitioned Parquet files out, with no lost events. Swap Kafka for a Kinesis stream and the exact same batch-then-write loop still applies.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What is an AWS Kinesis streaming sink?

A streaming sink reads records from a stream (Kinesis or Kafka) and writes them to durable storage, usually columnar Parquet files in a data lake. AWS Kinesis Firehose is the managed version; a Kafka consumer writing Parquet is the open-source equivalent, and both follow the same consume-batch-write loop.

How do I write Kafka messages to Parquet in Python?

Consume the topic with kafka-python, buffer messages in memory (grouped by a partition key such as date), then build a table with pyarrow.Table.from_pylist and write it via pyarrow.parquet.write_table with compression='snappy'. Flush every N messages and when the consumer drains.

How do I guarantee no data loss in a streaming sink?

Set enable_auto_commit=False on the consumer and call consumer.commit() only after the Parquet file is written to disk. This gives at-least-once delivery - if the sink crashes mid-batch, the uncommitted events are re-read on restart rather than lost.

How do I stop a Kafka consumer when the topic is empty?

Pass consumer_timeout_ms to KafkaConsumer (for example consumer_timeout_ms=5000). The iterator raises StopIteration and the for-loop exits when no message arrives within that window, which lets a batch sink finish cleanly instead of blocking forever.

Keep learning

Produce and Consume Kafka Messages in PythonData projectBuild an Exactly-Once Kafka PipelineData projectConvert CSV to Parquet With PyArrowData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →