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.
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
- Consuming a Kafka topic with enable_auto_commit=False for at-least-once delivery
- Buffering events by date and flushing each batch to snappy-compressed Parquet with PyArrow
- Committing offsets only after the Parquet write succeeds so a crash never loses data
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
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 →