AWS Data

Amazon Kinesis Hands-On: Data Streams vs Firehose for Real-Time Ingestion

At 9:58 on a Friday your clickstream service is fine. At 10:00 a marketing push lands and the ingest rate quadruples, and by 10:03 your producers are throwing ProvisionedThroughputExceededException, your consumer’s IteratorAge is climbing past ten minutes, and the “real-time” dashboard the CEO is watching is now twenty minutes stale. Nothing is down — every component returns 200s — but the pipeline has silently fallen behind, and the events that expired out of the buffer while you were paging the on-call are gone for good. This is the failure mode that separates people who have used streaming from people who have read about it: the problems are never a crash, they are backpressure, skew, and lag.

Amazon Kinesis is the AWS family for exactly this: getting a firehose of events from thousands of producers into somewhere useful, in seconds, without losing ordering or dropping records under load. It has two members people constantly confuse. Kinesis Data Streams (KDS) is a durable, replayable, sharded log — think a managed Kafka topic — that many independent consumers can read at their own pace, rewinding up to a year. Amazon Data Firehose (formerly Kinesis Data Firehose) is a fully managed delivery pipe with no shards to run: it buffers, optionally transforms, converts to columnar formats, and lands data in S3, Redshift, OpenSearch, Splunk, Iceberg or an HTTP endpoint. One gives you a replayable stream you build consumers on; the other gives you managed load-and-forget delivery. Choosing wrong — or worse, not knowing they are different — is how teams end up rebuilding a pipeline six months in.

This article is the hands-on, production-grade tour. You will learn the shard as the atomic unit of throughput and ordering, why a bad partition key creates a hot shard that no capacity can fix, every producer (SDK PutRecords, the KPL, the Kinesis Agent) and consumer (KCL, Lambda event source mapping, enhanced fan-out), retention and resharding, and the IteratorAge metric that tells you the truth about lag. Then you will learn Firehose end to end — buffering, the Lambda transform contract, Parquet conversion against a Glue schema, dynamic partitioning, retry and S3 backup — and the crisp KDS vs Firehose vs SQS vs Amazon MSK decision. You will build the whole thing in a copy-pasteable aws CLI + Terraform lab that produces records, transforms them, lands Parquet in S3, and queries it in Athena — then tear it down. And you will get a 15-row troubleshooting playbook for the day the push lands.

What problem this solves

The pain is not “we need a message bus.” The pain is that high-volume event data has four hard requirements at once — durable buffering so a slow consumer never drops data, ordering so a state machine can be rebuilt from the log, replay so a new consumer (or a bug fix) can reprocess history, and managed delivery so analytics land in a queryable lake without a fleet of servers — and no single primitive gives you all four. Kinesis splits the problem: KDS owns durable-ordered-replayable, Firehose owns managed-delivery, and you compose them.

Symptom in production Root cause (a design gap) What Kinesis gives you instead
A slow consumer drops events under a traffic spike You put ingestion behind a synchronous API or an in-memory queue with no durable buffer KDS decouples producers from consumers with a durable log; producers keep writing while consumers catch up
A bug corrupted a day of derived data and you cannot rebuild it Your transport deletes messages on read (a queue), so history is gone KDS retention (24 h–365 d) lets you rewind and reprocess from any point
Two teams both need the raw event feed and are fighting over it One queue = one logical consumer group; a second reader steals messages KDS supports many independent consumers, each with its own position, and enhanced fan-out for dedicated throughput
One “power user” tenant makes the whole pipeline lag A low-cardinality partition key concentrates traffic on one shard Higher-cardinality keys spread load across shards; on-demand absorbs skew automatically
Landing clickstream in the lake needs a Spark job and a server fleet You hand-rolled buffering, batching, compression and partitioning Firehose buffers, transforms, converts to Parquet and writes partitioned S3 objects with zero servers
Athena queries scan terabytes of raw JSON and cost a fortune You dumped raw JSON to S3 with no format or partitioning Firehose Parquet conversion + Hive partitions cut bytes scanned by 10–100×
Events arrive but the dashboard is minutes behind Buffering interval too long, or the consumer is under-provisioned Tune Firehose buffer hints; watch IteratorAge; add enhanced fan-out

Who hits this: anyone building clickstream analytics, IoT telemetry, application/security log pipelines, change-data-capture fan-out, real-time metrics, or fraud/anomaly scoring. The teams that thrive treat Kinesis as two purpose-built tools and pick per workload; the teams that suffer reach for one, force it to do the other’s job, and pay in lag, cost and lost data.

Learning objectives

By the end of this article you will be able to:

Prerequisites & where this fits

This is an Advanced Data article. You should be comfortable with the AWS CLI and IAM roles/policies, with S3 and basic partitioning, and with the idea of a serverless function. Nothing here needs a running Kafka or a data-engineering background — we build from first principles — but you will move faster if you have deployed a Lambda before.

You should already know Why it matters here If not, start with
IAM roles, trust policies, least privilege Firehose and Lambda assume service roles to read the stream and write S3/Glue An IAM roles primer
S3 buckets, prefixes, object keys Firehose lands objects under Hive-style dt=/hr= prefixes An S3 fundamentals article
Lambda basics (handler, timeout, IAM) Both the KDS consumer and the Firehose transform are Lambdas AWS Lambda Patterns: Event-Driven Functions That Scale to Zero
Glue Data Catalog + Athena Parquet conversion needs a Glue table; Athena queries the lake AWS Data Lake and Analytics Architecture: S3, Glue, Athena, Redshift and Kinesis
Queues vs streams mental model The KDS vs SQS decision hinges on it Amazon SQS Hands-On: Standard vs FIFO Queues, Visibility Timeout & DLQs

Where it fits in a bigger picture: Kinesis is the ingestion tier of a data platform. Upstream are producers (apps, agents, IoT, CDC). Downstream, KDS feeds real-time consumers (Lambda, Flink, KCL apps) and Firehose feeds the lake (S3/Parquet) and search/warehouse (OpenSearch/Redshift). It sits alongside event routing (Event-Driven Architecture on AWS: EventBridge, SQS, SNS, Lambda and Step Functions) — EventBridge routes discrete business events; Kinesis moves high-volume record streams.

Core concepts

Get the vocabulary exact, because every troubleshooting decision downstream depends on it.

Term What it is The one thing to remember
Stream A named, durable, ordered collection of records, made of shards The unit you create; capacity is the sum of its shards (or auto in on-demand)
Shard The atomic unit of capacity and ordering: 1 MB/s or 1,000 rec/s in, 2 MB/s out Order is guaranteed only within a shard, never across shards
Record One data blob (≤1 MB) plus a partition key and a sequence number Max 1 MB including the partition key; bigger needs chunking
Partition key A string you supply; its MD5 hash maps the record to a shard Same key → same shard → ordered; skewed keys → hot shard
Sequence number A unique, strictly increasing ID assigned per record per shard Ordering token; not globally ordered, only per-shard
Shard iterator A cursor a consumer uses to read from a position in a shard Expires after 5 minutes; you must keep calling GetRecords
Retention period How long records stay readable: 24 h default, up to 365 days The replay window; expired records are gone forever
Capacity mode Provisioned (you set shard count) or on-demand (AWS scales) On-demand removes hot-shard toil at higher per-GB cost
Consumer Anything that reads: KCL app, Lambda ESM, Firehose, Flink Shared throughput (2 MB/s/shard) or enhanced fan-out (2 MB/s/consumer/shard)
Enhanced fan-out (EFO) A registered consumer with dedicated 2 MB/s per shard via HTTP/2 push Buys isolation + ~70 ms latency; costs per consumer-shard-hour
IteratorAge Age of the last record a consumer read; the lag metric Climbing = falling behind; the single most important alarm

The two products at a glance

Dimension Kinesis Data Streams (KDS) Amazon Data Firehose Amazon MSK (Kafka) Amazon SQS
Primitive Sharded, replayable log Managed delivery pipe Kafka topics/partitions Message queue
You manage Shards (or on-demand) Nothing (serverless) Broker cluster (or Serverless) Nothing (serverless)
Ordering Per shard Not a design goal (delivery) Per partition FIFO queues only
Replay / retention Yes, 24 h–365 d No (delivers then done) Yes, configurable No (deleted on ack)
Multiple consumers Yes (many, independent) N/A (it is the consumer) Yes (consumer groups) One logical group
Typical latency ~200 ms (poll) / ~70 ms (EFO) ~60 s+ (buffered) ~ms ~ms
Best at Real-time processing + replay Load-and-forget to a store Kafka ecosystem/tooling Decoupling work items

Two mental hooks. First, Firehose is often a consumer of KDS, not a competitor — a very common pattern is KDS for real-time consumers and a Firehose reading the same stream to archive everything to S3. Second, Managed Service for Apache Flink (formerly Kinesis Data Analytics) is the processing tier: it reads KDS/MSK, runs windowed SQL or Flink jobs (aggregations, joins, anomaly detection), and writes results onward. We point to it rather than cover it in depth.

Kinesis Data Streams: shards, capacity and keys

A stream is a bag of shards. Everything about KDS throughput, cost, ordering and failure modes traces back to the shard, so start there.

Shard limits — the numbers you must memorize

Limit Value Notes
Write per shard (bytes) 1 MB/s Includes partition keys; sustained
Write per shard (records) 1,000 records/s Whichever cap you hit first applies
Read per shard — shared 2 MB/s Shared across all classic consumers of that shard
GetRecords call rate 5 calls/s per shard Per shard, across consumers
GetRecords per call ≤10 MB and ≤10,000 records A big call then forces a short cooldown
Read per shard — EFO 2 MB/s per registered consumer Dedicated pipe; does not share the 2 MB/s
Max record size 1 MB (1,048,576 bytes) Blob + partition key
PutRecords batch ≤500 records and ≤5 MB per call Partial failures returned per record
Registered EFO consumers 20 per stream (default) Soft quota
Provisioned shards per stream 500 (US East/West, EU) / lower elsewhere Soft quota; request increases

The cap that bites first is usually 1,000 records/s, not 1 MB/s — teams sending tiny 200-byte events hit the record cap at 200 KB/s of actual data, far below the 1 MB budget. If your records are small, either aggregate them (KPL) or plan shards on the record-rate cap.

On-demand vs provisioned capacity mode

Aspect Provisioned On-demand
Who sizes it You (set/adjust shard count) AWS (auto-scales)
Scales on Your UpdateShardCount / resharding Automatically, up to 2× the last 30-day peak
Starting capacity Your chosen shards 4 MB/s and 4,000 records/s write
Ceiling (default) 500 shards ≈ 500 MB/s 200 MB/s write, 200,000 rec/s (soft quota)
Read 2 MB/s/shard shared + EFO Managed; EFO supported
Hot-shard toil Yours to solve (keys, resharding) Absorbed automatically
Cost model Per shard-hour + per PUT unit Per GB ingested + per GB retrieved + stream-hour
Best when Steady, predictable, cost-sensitive Spiky/unknown traffic, want zero toil
Switch frequency Between modes twice per 24 h

Rule of thumb: start on-demand for anything new or spiky — it removes the hardest operational problem (hot shards and reshard timing) — and move to provisioned once traffic is predictable and you have done the math showing provisioned is cheaper.

Sizing math — how many shards?

Input Example Formula
Peak record rate 30,000 rec/s
Peak data rate 12 MB/s
Shards for records 30 ceil(30,000 / 1,000)
Shards for bytes 12 ceil(12 MB / 1 MB)
Shards required 30 max(records-bound, bytes-bound)
Headroom (add ~25%) ~38 Absorb bursts + skew

Always size on the max of the two bounds and add headroom, because traffic is never perfectly even across keys. If 38 shards feels like a lot of tuning, that is the exact moment on-demand earns its price premium.

Partition keys and the hot-shard problem

A record’s partition key is hashed (MD5) into a 128-bit space; each shard owns a contiguous slice of that space. Ordering is per key (same key → same shard → strictly increasing sequence numbers), which is a feature — but if too many records share one key (or a few keys), they pile onto one shard and you get a hot shard: that shard throttles at 1 MB/s while the rest sit idle.

Partition key strategy Cardinality Ordering guarantee Hot-shard risk Use when
Constant ("all") 1 Total order (single shard) Severe — 1 shard only You truly need one global order and low volume
Low-cardinality (region, status) Tens Per region High if skewed A few big tenants dominate
Entity id (user, device, order) Millions Per entity Low, if evenly distributed The common, correct default
Composite (tenant#user) High Per tenant+user Low Multi-tenant with per-user order
Random / UUID Very high None (spreads a key’s records) Lowest You do not need ordering at all
ExplicitHashKey You choose You control shard placement You manage KPL/advanced routing

The trap: you need some ordering (per user, say), so you key by userId — correct — but one whale user generates 40% of traffic and melts their shard. Fixes, in order of preference: on-demand (auto-absorbs), a higher-cardinality composite key if per-whale order is not required, write sharding the whale (userId#0..N with scatter-gather on read), or more shards so the hash space is finer-grained. You cannot fix a genuinely skewed key by only adding shards if all the hot records share one key — they still land on one shard.

Producers: getting data in

Producer What it is Throughput approach Ordering Best for
PutRecord Single-record SDK call One record per HTTP call Per key Low volume, simplest code
PutRecords Batch SDK call, ≤500 rec / ≤5 MB Amortizes HTTP overhead Per key; partial failures Most application producers
KPL (Kinesis Producer Library) C++ daemon + Java/other wrapper Aggregates many user records into one 1 MB record; batches; async; retries Per key High throughput, small records
Kinesis Agent Java agent that tails files Batches file lines to KDS/Firehose Per key Log/file shipping, no code
Firehose Direct PUT PutRecordBatch to Firehose Managed N/A When you only want delivery

Two producer facts that cause real incidents. First, PutRecords returns partial success: the HTTP call can be 200 while individual records failed with ErrorCode ProvisionedThroughputExceededException or InternalFailure. You must inspect FailedRecordCount and resend only the failed records — naive code that checks only the HTTP status silently drops data. Second, the KPL aggregates user records into larger KDS records to beat the 1,000 rec/s cap; the trade-off is that consumers must de-aggregate (the KCL does this automatically; a raw Lambda needs the KPL de-aggregation helper).

PutRecord vs PutRecords PutRecord PutRecords
Records per call 1 Up to 500
Max payload per call 1 MB 5 MB
Failure granularity Whole call Per record (FailedRecordCount)
Ordering across the call N/A Not guaranteed to preserve request order on retry
Efficiency Low (1 HTTP/record) High (amortized)
Retry responsibility Yours Yours, per failed record

Consumers: getting data out

This is where KDS beats a queue: many consumers can read the same records independently, each at its own position, and replay history.

Consumer How it reads Throughput Latency Checkpointing Best for
KCL (Kinesis Client Library) Long-poll GetRecords; one worker per shard 2 MB/s/shard shared ~200 ms–1 s DynamoDB lease table Custom stateful stream apps
Lambda event source mapping Kinesis polls, invokes your function with a batch 2 MB/s/shard shared (or EFO) ~200 ms–1 s (poll) Managed by ESM Serverless processing
Enhanced fan-out SubscribeToShard HTTP/2 push 2 MB/s per consumer per shard ~70 ms Depends on client (KCL/Lambda) Multiple/latency-sensitive consumers
Firehose Reads the stream as a consumer Managed ~60 s+ (buffered) Managed Deliver to S3/Redshift/OpenSearch
Managed Service for Apache Flink Flink source connector Scales with parallelism Sub-second Flink checkpoints Windowed analytics, joins

Shared throughput vs enhanced fan-out

Aspect Shared (classic) Enhanced fan-out (EFO)
Bandwidth 2 MB/s shared across all consumers of a shard 2 MB/s dedicated per registered consumer per shard
Delivery Pull (GetRecords, 5 calls/s cap) Push (SubscribeToShard, HTTP/2)
Latency ~200 ms typical (poll interval) ~70 ms typical
Consumers before contention ~2–3 before they starve each other Up to 20 registered, each isolated
Extra cost None beyond shard/PUT Per consumer-shard-hour + per GB retrieved
Use when 1–2 consumers, cost-sensitive Many consumers or you need low latency

The classic reason to reach for EFO: you have three teams all consuming one stream and they are starving each other on the shared 2 MB/s, and/or a fraud consumer needs sub-100 ms latency. EFO gives each its own dedicated pipe. The cost is real, so do not enable it reflexively for a single consumer.

Lambda event source mapping — the knobs

Lambda is the most common KDS consumer, and its event source mapping (ESM) has a dozen tuning parameters that decide throughput, latency and failure behavior.

ESM setting Range / values Default Effect / when to change
Batch size 1–10,000 records 100 Larger batch = higher throughput, higher per-invoke latency
Batch window (MaximumBatchingWindowInSeconds) 0–300 s 0 Wait to fill a batch; trades latency for fewer invokes
Starting position LATEST / TRIM_HORIZON / AT_TIMESTAMP TRIM_HORIZON reprocesses from oldest retained
Parallelization factor 1–10 1 Concurrent batches per shard; more consumer parallelism while keeping per-key order
Retry attempts (MaximumRetryAttempts) 0–10,000 / -1 (infinite) -1 Cap poison-pill retries
Max record age (MaximumRecordAgeInSeconds) 60–604,800 / -1 -1 Skip records older than N (drop stragglers)
Bisect on error (BisectBatchOnFunctionError) true/false false Split a failing batch to isolate the poison record
On-failure destination SQS / SNS ARN none Send failed batch metadata to a DLQ
Report batch item failures enabled/disabled disabled Return partial success so only failed records retry
Tumbling window 0–900 s none Stateful aggregation across batches
Concurrency (EFO consumer) register consumer ARN shared Attach ESM to an EFO consumer for dedicated throughput

The most abused defaults: retries default to infinite and bisect is off, so a single un-deserializable “poison” record can block its shard forever while IteratorAge climbs and every good record behind it waits. The fix is to set a finite MaximumRetryAttempts, enable BisectBatchOnFunctionError, configure an on-failure destination, and — best of all — implement ReportBatchItemFailures so your function returns only the sequence numbers that failed and the rest are marked done.

Retention, ordering and resharding

Retention

Setting Value Cost tier Notes
Default retention 24 hours Included The replay window out of the box
Extended retention Up to 7 days (168 h) Extra per shard-hour IncreaseStreamRetentionPeriod
Long-term retention 7–365 days (8,760 h) Extra per GB-month + retrieval Reads older than 24 h bill a retrieval fee
Minimum 24 hours Cannot go below 24 h

Retention is your safety margin. Set it comfortably above your worst-case consumer downtime: if a bad deploy can wedge a consumer for six hours, 24 h retention is thin. Long-term retention (up to a year) turns the stream into a cheap replay archive, but reads beyond 24 h incur a retrieval charge, so it is for occasional reprocessing, not routine reads.

Ordering guarantees

Scope Guarantee Caveat
Within a shard Strict order by sequence number The core guarantee
Within a partition key Strict order (same key → same shard) Holds only while the key maps to one shard
Across shards No ordering Never assume global order
Across a reshard Preserved per key via parent→child Consumer must drain the parent shard first
Delivery semantics At least once Design consumers to be idempotent

Two things burn people. At-least-once means duplicates happen (producer retries, consumer reprocessing after a checkpoint gap) — make writes idempotent with a dedup key. And order across a reshard is preserved only if the consumer reads the closed parent shard fully before its children; the KCL and Lambda ESM handle this for you, hand-rolled GetRecords code must respect the parent/child lineage.

Resharding — adding and removing capacity

Operation What it does Capacity effect When
Split shard One shard → two children (splits hash range) +1 shard of capacity Relieve a hot shard / scale up
Merge shards Two adjacent shards → one −1 shard of capacity Scale down / cut cost
UpdateShardCount Uniform scale to a target count Set N shards Bulk scale (up to 2× or down to ½ per call)
On-demand AWS reshards for you Auto You do nothing
UpdateShardCount limit Value
Max scale-up per call 2× current count
Max scale-down per call ½ current count
Scaling operations per rolling 24 h 10 (soft)
Behavior Creates new shards, closes old ones; brief per-shard reshuffle

Resharding is not instant and it churns shard IDs (old shards go to CLOSED, new ones appear), which is exactly why on-demand exists. The IteratorAge climbing on one shard while others are flat is the classic signal that you have a hot shard and need to split it (provisioned) or that you should switch to on-demand.

The CloudWatch metrics that matter

Metric Meaning Alarm when
GetRecords.IteratorAgeMilliseconds Age of the last record read (lag) Climbing / high — consumer falling behind
WriteProvisionedThroughputExceeded Writes throttled > 0 — hot shard or under-provisioned
ReadProvisionedThroughputExceeded Reads throttled > 0 — too many/greedy consumers; use EFO
IncomingBytes / IncomingRecords Ingest volume Near shard cap — reshard/on-demand
PutRecord.Success / PutRecords.FailedRecordCount Producer success Failures rising — retry/backoff
SubscribeToShard.RateExceeded EFO subscribe throttled > 0 — too many subscribe calls
MillisBehindLatest (Firehose/Flink) Consumer lag for that reader Climbing — under-provisioned consumer

IteratorAge is the north star. A healthy consumer keeps it near zero (a few hundred ms); a rising sawtooth means each poll falls further behind and you are heading for data loss once age exceeds retention.

Amazon Data Firehose: managed delivery

Firehose is the opposite philosophy: no shards, no consumers to write, no scaling to manage. You point a delivery stream at a source and a destination, set buffering and (optionally) a transform and format conversion, and it delivers. It is near-real-time (buffered, ~60 s+), not sub-second.

Sources and destinations

Source How Notes
Direct PUT PutRecord / PutRecordBatch to Firehose Simplest; app writes straight to Firehose
Kinesis Data Stream Firehose reads a KDS as a consumer Get replay + other consumers and managed delivery
Amazon MSK Reads a Kafka topic Kafka → S3/lake without custom code
CloudWatch Logs / Events, WAF, etc. Native integrations Log/event archival
Destination Delivers as Typical use
Amazon S3 Objects (JSON/Parquet/ORC), compressed, partitioned Data lake — the default
Amazon Redshift COPY from an intermediate S3 bucket Warehouse loading
OpenSearch Service / Serverless Bulk-indexed documents Log search & dashboards
Splunk HEC endpoint Splunk-based SIEM/observability
Snowflake Snowpipe Streaming Snowflake warehouse
Apache Iceberg tables Iceberg on S3 (inserts/updates) Open table format lakehouse
Generic HTTP endpoint HTTP POST (with retries) Datadog, New Relic, Dynatrace, MongoDB, custom

The single most useful pattern in this whole article: KDS as the source, S3 as the destination, Parquet conversion on. You keep a replayable stream that real-time consumers (Lambda, Flink) read, and Firehose quietly archives every record to a query-ready Parquet lake — one stream, two jobs done.

Buffering hints

Firehose batches records into one delivered object, flushing when either the size or the interval threshold is hit first.

Destination Buffer size range Buffer interval range Note
S3 1–128 MB 60–900 s Whichever hits first flushes
S3 with dynamic partitioning 64 MB minimum 60–900 s Higher floor to make partitions efficient
OpenSearch 1–100 MB 60–900 s
Splunk / HTTP ~1–64 MB (raw payload) 60–900 s Endpoint-dependent
Redshift Via S3 buffer, then COPY 60–900 s Intermediate S3 object first

Tuning is a latency-vs-efficiency dial: short interval + small size = fresher data but many tiny files (bad for Athena — the “small files problem”); long interval + big size = efficient large objects but staler data. For a lake, prefer larger buffers (bigger Parquet files scan faster) and accept ~1–5 minutes of latency; for near-real-time dashboards, shorten the interval and compact later.

Data transformation with Lambda

Firehose can invoke a Lambda on each buffered batch to clean, enrich, filter or reformat records before delivery (and before format conversion).

Transform aspect Rule
Invocation Firehose sends a batch (buffer up to 3 MB by default, tunable 0.2–3 MB)
Return contract For every input record, return recordId, result, and base64 data
result values Ok (deliver), Dropped (discard cleanly), ProcessingFailed (route to backup)
Must return All records — count in = count out, or Firehose treats it as a failure
Timeout Up to 5 minutes
Failure handling ProcessingFailed/exceptions → processing-failed/ S3 prefix (if S3 backup on)
Idempotency Transform may be retried — keep it side-effect-free

The contract catches everyone once: your function must return every record it received, each tagged. Drop a record by returning fewer than you got and Firehose flags the whole batch as a mismatch; drop a record cleanly by returning it with result: "Dropped". Return ProcessingFailed for bad records so they land in the backup prefix instead of vanishing.

Format conversion to Parquet/ORC

Setting Detail
Input format JSON only (deserialize with OpenX JSON SerDe or Hive JSON SerDe)
Output format Parquet or ORC (columnar)
Schema source A Glue Data Catalog table — Firehose reads column names/types from it
Compression Parquet/ORC internal codec (Snappy default, or GZIP/ZLIB)
Interaction with S3 compression Do not also set S3 GZIP — the columnar codec handles it
Region Glue table must be in the same region as the delivery stream

Format conversion is what turns Firehose into a lake-builder: raw JSON events become Snappy-compressed Parquet that Athena scans 10–100× cheaper. The dependency to internalize: the Glue table is the schema. If a producer adds a field the Glue table does not know, or a type drifts (a number arriving as a string), conversion fails and those records go to the errors/ prefix. Evolve the Glue schema additively and keep the JSON deserializer permissive.

Dynamic partitioning

Aspect Detail
What it does Routes each record to an S3 prefix derived from its content
Key extraction Inline jq expressions, or values returned by the transform Lambda
Prefix syntax curated/dt=!{partitionKeyFromQuery:dt}/hr=!{...}/
Minimum buffer 64 MB (partitions need enough data to be efficient)
Cost Extra per GB processed + per partitioned object
Fallback Records that fail key extraction go to an error prefix

Dynamic partitioning writes Hive-style dt=/hr= prefixes straight from event fields (e.g. the event’s own timestamp, not arrival time), so Athena partition pruning works with no post-processing. Without it, you either accept coarse arrival-time partitions or run a compaction/repartition job later.

Firehose reliability: retry, backup, encryption

Mechanism Behavior
Delivery retry Configurable duration (0–7,200 s) for the destination before giving up
S3 backup AllData (everything) or FailedDataOnly (only failed/transform-failed) to a backup bucket
Error prefixes processing-failed/ (transform), errors/ (format-conversion), elasticsearch-failed/ etc.
Encryption at rest Destination SSE (S3-SSE or SSE-KMS); source SSE-KMS for Direct PUT
Encryption in transit TLS to destination
Backpressure Firehose buffers/retries; if a destination is down long enough, failed data → backup S3

Turn on S3 backup (at least FailedDataOnly) on day one. It is the difference between “a schema drift dropped 0.2% of records into a recoverable prefix” and “we lost 0.2% of records and cannot say which.”

KDS vs Firehose vs SQS vs MSK: the decision

This is the question interviewers and architecture reviews actually ask. Line them up on the dimensions that decide it.

Dimension Kinesis Data Streams Data Firehose Amazon SQS Amazon MSK
Core job Replayable, ordered stream for real-time consumers Managed delivery to a store Decouple + buffer work items Kafka-compatible streaming
Replay after read Yes (retention window) No No (deleted on ack) Yes
Ordering Per shard N/A FIFO queues only Per partition
Multiple independent consumers Yes (many) It is the delivery One logical consumer group Yes (consumer groups)
Throughput unit Shard (1 MB/s) Auto (serverless) Nearly unlimited Partition
Latency ~70–200 ms ~60 s+ ~ms ~ms
Ops burden Shards / on-demand None None Cluster (or Serverless)
Delivery semantics At least once At least once At least once (exactly-once FIFO) At least once
Pricing driver Shard-hour / GB Per GB delivered Per request Broker-hour / storage
Kafka tooling/ecosystem No No No Yes (Connect, Streams, Schema Registry)
If you need… Choose
Replay + multiple real-time consumers of high-volume events KDS
Just land streaming data in S3/Redshift/OpenSearch, no servers Firehose (often reading a KDS)
Decouple a producer from one worker pool, retry failed items, DLQ SQS
Fan a notification to many subscribers SNS (often SNS→SQS fan-out)
Existing Kafka apps, Kafka Connect, Streams API, Schema Registry Amazon MSK
Windowed aggregation / joins / anomaly detection on a stream Managed Service for Apache Flink (reads KDS/MSK)
Discrete, schema-rich business events routed by rules EventBridge

The clean one-liners: SQS is a queue (one consumer group, delete-on-read, no replay) — reach for it to decouple work, not to broadcast a feed. KDS is a log (many consumers, replay, ordering) — reach for it when more than one thing needs the data or you might reprocess. Firehose is a pipe (managed delivery, no consumers to write) — reach for it to load a store. MSK is Kafka — reach for it when the ecosystem (Connect, KStreams, existing tooling) is the requirement, not just streaming.

Architecture at a glance

The pipeline reads left to right. Producers — your app via the SDK, the KPL for high volume, or the Kinesis Agent for log files — batch records with PutRecords (badge 1) into a Kinesis Data Stream running in on-demand mode. Each record’s partition key hashes to a shard (badge 2), the unit of both throughput (1 MB/s in, 2 MB/s out) and ordering; the stream retains records for replay so any consumer can rewind. Data Firehose consumes the stream (badge 3), buffers by size or interval, invokes a Lambda transform that must return every record tagged Ok/Dropped/ProcessingFailed (badge 4), then converts JSON to Parquet against a Glue table schema (badge 5) and lands compressed, dt=/hr=-partitioned objects in an S3 data lake. Athena queries that Parquet in place (badge 6), scanning only the partitions and columns a query needs. The key insight the diagram encodes: KDS gives you replay and many consumers; Firehose gives you managed, query-ready delivery — and here they compose into one pipeline.

Left-to-right AWS ingestion architecture: producers using SDK, KPL and the Kinesis Agent batch records with PutRecords into a Kinesis Data Stream made of shards running in on-demand mode; Data Firehose consumes the stream, buffers 128 MB or 300 s, invokes a Lambda transform that returns all records, converts JSON to Snappy Parquet using a Glue Data Catalog table, and writes dt= and hr= partitioned objects into an S3 curated bucket that Amazon Athena queries in place with partition projection. Six numbered badges mark producer batching, the shard as throughput and ordering unit, Firehose buffering, the Lambda transform return contract, Parquet conversion against the Glue schema, and Athena querying S3.

Real-world scenario

PulseCart, a mid-size retail platform, runs a clickstream pipeline: every page view, add-to-cart and search from ~2 million daily shoppers, peaking at 18,000 events/second on sale days. Three teams need the feed. Personalization needs it in real time (sub-200 ms) to update recommendations. Fraud needs low-latency access to score sessions. Analytics needs every event in the lake for next-day reporting. The original design was a single SQS queue that Personalization drained — which meant Fraud and Analytics could not get the same events without stealing them, so Analytics ran a nightly database export that was always a day stale and missed the sale-day spikes entirely.

They redesigned around KDS on-demand. Producers (the web tier via the KPL, aggregating small events to beat the 1,000 rec/s-per-shard cap) write to one clickstream stream. Personalization consumes via a Lambda ESM with ReportBatchItemFailures and BisectBatchOnFunctionError, so one malformed event no longer wedges a shard. Fraud, which was starving on the shared 2 MB/s, moved to a registered enhanced fan-out consumer — dedicated 2 MB/s and ~70 ms latency, isolated from the others. Analytics stopped running exports entirely: a Firehose now consumes the same stream, runs a Lambda transform that drops bot traffic and enriches geo, converts to Parquet against a Glue table, and lands dt=/hr= partitioned objects in S3 that Athena queries next morning.

The first sale day after cutover, ingest hit 41,000 events/second — more than double the previous peak — and on-demand absorbed it with no paging and no throttling. The one incident: a new “wishlist” event added a wishlistId field the Glue table did not have, so ~0.3% of records failed format conversion. Because S3 backup (FailedDataOnly) was on, those records sat safely in the errors/ prefix; the team added the column to the Glue table (additive, backward-compatible), re-ran a small backfill from the backup prefix, and lost nothing. Post-mortem numbers: Analytics latency went from ~24 hours to ~3 minutes, Athena cost dropped ~70% versus the old raw-JSON exports (Parquet + partition pruning), and Fraud’s p99 read latency fell from ~800 ms (starved shared reads) to ~70 ms (EFO). The lesson they wrote down: one stream, many consumers, and let Firehose build the lake — do not make three teams fight over one queue.

Advantages and disadvantages

Kinesis Data Streams Data Firehose
Advantages Replay (24 h–365 d); many independent consumers; strict per-shard ordering; ~70 ms with EFO; on-demand removes hot-shard toil; integrates with Lambda/KCL/Flink Zero infrastructure; auto-scales; built-in Parquet/ORC conversion; dynamic partitioning; transform + retry + S3 backup; direct to S3/Redshift/OpenSearch/Splunk/Iceberg
Disadvantages You must design keys and size/reshard (unless on-demand); shared 2 MB/s limits fan-out without EFO; at-least-once (dedup yourself); more moving parts ~60 s+ latency (not sub-second); no replay (delivers then done); Glue-schema-coupled; small-file risk if over-tuned; per-feature costs add up

When each matters: choose KDS whenever more than one thing needs the data, you might reprocess, or you need ordering and sub-second latency — its cost and key-design work buy you a replayable, multi-consumer log nothing else on this list provides. Choose Firehose when the job is purely “get this stream into a store, query-ready, with no servers” — its lack of replay is irrelevant because a store is the durable copy, and its Parquet/partitioning turns a lake from expensive to cheap. The strongest designs use both: KDS as the durable multi-consumer spine, Firehose hanging off it to build the lake.

Hands-on lab

You will build the full pipeline: producer → KDS (on-demand)Firehose (Lambda transform → Parquet via a Glue table) → S3Athena. Everything here is small and cheap, but ⚠️ KDS, Firehose, Glue, Athena and S3 all cost money — on-demand KDS bills per GB and a stream-hour even when idle, so do the teardown at the end. Region is us-east-1; swap as needed.

Step 0 — Variables and prerequisites

export AWS_REGION=us-east-1
export ACCT=$(aws sts get-caller-identity --query Account --output text)
export BUCKET="pulsecart-lab-lake-${ACCT}"
export STREAM="orders-stream"
export FH="orders-firehose"
export DB="pulsecart_lab"
export TABLE="orders"
aws s3 mb "s3://${BUCKET}" --region "$AWS_REGION"

Step 1 — Create the Data Stream (on-demand)

aws kinesis create-stream \
  --stream-name "$STREAM" \
  --stream-mode-details StreamMode=ON_DEMAND \
  --region "$AWS_REGION"

# Wait until ACTIVE
aws kinesis wait stream-exists --stream-name "$STREAM"
aws kinesis describe-stream-summary --stream-name "$STREAM" \
  --query 'StreamDescriptionSummary.{Mode:StreamModeDetails.StreamMode,Status:StreamStatus,Shards:OpenShardCount}'

Expected output:

{ "Mode": "ON_DEMAND", "Status": "ACTIVE", "Shards": 4 }

On-demand starts with 4 shards (4 MB/s / 4,000 rec/s) and scales automatically.

Step 2 — Create the Glue database and table (the Parquet schema)

Firehose needs a Glue table to know the columns and types for Parquet conversion.

aws glue create-database --database-input "{\"Name\":\"${DB}\"}"

aws glue create-table --database-name "$DB" --table-input '{
  "Name": "orders",
  "StorageDescriptor": {
    "Columns": [
      {"Name":"order_id","Type":"string"},
      {"Name":"customer_id","Type":"string"},
      {"Name":"amount","Type":"double"},
      {"Name":"currency","Type":"string"},
      {"Name":"event_time","Type":"string"}
    ],
    "Location": "s3://'"$BUCKET"'/curated/",
    "InputFormat": "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat",
    "OutputFormat": "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat",
    "SerdeInfo": {"SerializationLibrary": "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"}
  },
  "PartitionKeys": [{"Name":"dt","Type":"string"}],
  "TableType": "EXTERNAL_TABLE"
}'

Step 3 — The transform Lambda (returns ALL records)

Create transform.py — it uppercases the currency, drops test orders cleanly, and returns every record tagged:

import base64, json

def handler(event, context):
    out = []
    for r in event["records"]:
        payload = json.loads(base64.b64decode(r["data"]))
        # Drop synthetic test traffic cleanly
        if payload.get("customer_id", "").startswith("test-"):
            out.append({"recordId": r["recordId"], "result": "Dropped"})
            continue
        payload["currency"] = payload.get("currency", "usd").upper()
        # Firehose Parquet conversion wants newline-delimited JSON
        data = (json.dumps(payload) + "\n").encode("utf-8")
        out.append({
            "recordId": r["recordId"],
            "result": "Ok",
            "data": base64.b64encode(data).decode("utf-8"),
        })
    return {"records": out}   # count in == count out

Package and deploy (assumes an execution role lab-fh-transform-role with AWSLambdaBasicExecutionRole):

zip transform.zip transform.py
aws lambda create-function \
  --function-name fh-transform \
  --runtime python3.12 --handler transform.handler \
  --timeout 60 --zip-file fileb://transform.zip \
  --role "arn:aws:iam::${ACCT}:role/lab-fh-transform-role"

Step 4 — Create the Firehose (source = KDS, transform + Parquet → S3)

Firehose needs a role that can read the stream, invoke the Lambda, read the Glue table and write S3 (see Security notes for the policy). Assuming role lab-firehose-role:

export STREAM_ARN=$(aws kinesis describe-stream-summary --stream-name "$STREAM" \
  --query 'StreamDescriptionSummary.StreamARN' --output text)

aws firehose create-delivery-stream \
  --delivery-stream-name "$FH" \
  --delivery-stream-type KinesisStreamAsSource \
  --kinesis-stream-source-configuration "KinesisStreamARN=${STREAM_ARN},RoleARN=arn:aws:iam::${ACCT}:role/lab-firehose-role" \
  --extended-s3-destination-configuration '{
    "RoleARN": "arn:aws:iam::'"$ACCT"':role/lab-firehose-role",
    "BucketARN": "arn:aws:s3:::'"$BUCKET"'",
    "Prefix": "curated/dt=!{timestamp:yyyy-MM-dd}/",
    "ErrorOutputPrefix": "errors/!{firehose:error-output-type}/dt=!{timestamp:yyyy-MM-dd}/",
    "BufferingHints": {"SizeInMBs": 128, "IntervalInSeconds": 60},
    "S3BackupMode": "Disabled",
    "ProcessingConfiguration": {
      "Enabled": true,
      "Processors": [{
        "Type": "Lambda",
        "Parameters": [{"ParameterName":"LambdaArn","ParameterValue":"arn:aws:lambda:'"$AWS_REGION"':'"$ACCT"':function:fh-transform"}]
      }]
    },
    "DataFormatConversionConfiguration": {
      "Enabled": true,
      "InputFormatConfiguration": {"Deserializer": {"OpenXJsonSerDe": {}}},
      "OutputFormatConfiguration": {"Serializer": {"ParquetSerDe": {}}},
      "SchemaConfiguration": {
        "RoleARN": "arn:aws:iam::'"$ACCT"':role/lab-firehose-role",
        "DatabaseName": "'"$DB"'", "TableName": "'"$TABLE"'", "Region": "'"$AWS_REGION"'"
      }
    }
  }'

Step 5 — Produce records

Put a few records, including one test- record to prove the transform drops it:

for i in 1 2 3 4 5; do
  aws kinesis put-record --stream-name "$STREAM" \
    --partition-key "cust-${i}" \
    --data "$(printf '{"order_id":"o%03d","customer_id":"cust-%d","amount":%d.50,"currency":"usd","event_time":"2026-07-14T10:0%d:00Z"}' $i $i $((i*10)) $i | base64)"
done
# a record the transform should DROP:
aws kinesis put-record --stream-name "$STREAM" --partition-key "test" \
  --data "$(echo '{"order_id":"o999","customer_id":"test-bot","amount":0.0,"currency":"usd","event_time":"2026-07-14T10:09:00Z"}' | base64)"

Expected output per call:

{ "ShardId": "shardId-000000000002", "SequenceNumber": "4966...0001" }

Step 6 — Wait for delivery, then verify Parquet in S3

Firehose flushes on the 60 s interval (records are far below 128 MB). Wait ~90 s.

aws s3 ls "s3://${BUCKET}/curated/" --recursive

Expected — a Parquet object under a dt= partition:

2026-07-14 10:11:42   1284 curated/dt=2026-07-14/orders-firehose-1-2026-07-14-10-10-...parquet

Step 7 — Register the partition and query in Athena

aws athena start-query-execution \
  --query-string "ALTER TABLE ${DB}.${TABLE} ADD IF NOT EXISTS PARTITION (dt='2026-07-14') LOCATION 's3://${BUCKET}/curated/dt=2026-07-14/'" \
  --query-execution-context "Database=${DB}" \
  --result-configuration "OutputLocation=s3://${BUCKET}/athena-results/"

aws athena start-query-execution \
  --query-string "SELECT customer_id, amount, currency FROM ${DB}.${TABLE} WHERE dt='2026-07-14' ORDER BY amount DESC" \
  --query-execution-context "Database=${DB}" \
  --result-configuration "OutputLocation=s3://${BUCKET}/athena-results/"

Fetch results with the returned QueryExecutionId via aws athena get-query-results. Expected: 5 rows (the five real orders), currency = USD (uppercased by the transform), and no test-bot row (dropped). That end-to-end proves ingestion, ordering, transform, Parquet conversion and query.

Terraform equivalent (the durable version)

resource "aws_kinesis_stream" "orders" {
  name             = "orders-stream"
  retention_period = 24
  stream_mode_details { stream_mode = "ON_DEMAND" }
}

resource "aws_s3_bucket" "lake" { bucket = "pulsecart-lab-lake-${data.aws_caller_identity.me.account_id}" }

resource "aws_kinesis_firehose_delivery_stream" "orders" {
  name        = "orders-firehose"
  destination = "extended_s3"

  kinesis_source_configuration {
    kinesis_stream_arn = aws_kinesis_stream.orders.arn
    role_arn           = aws_iam_role.firehose.arn
  }

  extended_s3_configuration {
    role_arn            = aws_iam_role.firehose.arn
    bucket_arn          = aws_s3_bucket.lake.arn
    prefix              = "curated/dt=!{timestamp:yyyy-MM-dd}/"
    error_output_prefix = "errors/!{firehose:error-output-type}/dt=!{timestamp:yyyy-MM-dd}/"
    buffering_size      = 128
    buffering_interval  = 60

    processing_configuration {
      enabled = true
      processors {
        type = "Lambda"
        parameters {
          parameter_name  = "LambdaArn"
          parameter_value = "${aws_lambda_function.transform.arn}:$LATEST"
        }
      }
    }

    data_format_conversion_configuration {
      input_format_configuration { deserializer { open_x_json_ser_de {} } }
      output_format_configuration { serializer { parquet_ser_de {} } }
      schema_configuration {
        role_arn      = aws_iam_role.firehose.arn
        database_name = aws_glue_catalog_database.lab.name
        table_name    = aws_glue_catalog_table.orders.name
        region        = var.region
      }
    }
  }
}

Teardown (⚠️ do this — on-demand KDS and Firehose bill while they exist)

aws firehose delete-delivery-stream --delivery-stream-name "$FH"
aws kinesis delete-stream --stream-name "$STREAM" --enforce-consumer-deletion
aws lambda delete-function --function-name fh-transform
aws glue delete-table --database-name "$DB" --name "$TABLE"
aws glue delete-database --name "$DB"
aws s3 rm "s3://${BUCKET}" --recursive
aws s3 rb "s3://${BUCKET}"

Common mistakes & troubleshooting

The failure modes are lag, skew, silent drops and schema drift — never a clean crash. Work the playbook top-down.

# Symptom Root cause Confirm (exact command / console path) Fix
1 ProvisionedThroughputExceededException on writes Shard write cap (1 MB/s or 1,000 rec/s) exceeded, or hot key CloudWatch WriteProvisionedThroughputExceeded > 0; check per-shard IncomingRecords Batch with PutRecords; KPL-aggregate; better partition key; add shards or switch to on-demand
2 One shard hot, others idle Partition-key skew (a whale key) CloudWatch per-ShardId IncomingBytes; one shard near 1 MB/s Higher-cardinality/composite key; write-shard the whale (key#0..N); split the shard
3 IteratorAge climbing steadily Consumer under-provisioned or slow function GetRecords.IteratorAgeMilliseconds rising; MillisBehindLatest high Raise Lambda batch size / parallelization factor; optimize function; enhanced fan-out; more shards
4 Records “lost” / never processed Retention expired before the consumer caught up Compare max IteratorAge to retention (24 h default) Increase retention (IncreaseStreamRetentionPeriod); fix/scale the consumer; alarm on IteratorAge
5 One shard’s whole batch stuck; IteratorAge climbs on it only Poison record + infinite retries block the shard (ordering) Lambda error metrics on one shard; ESM MaximumRetryAttempts=-1 Set finite retries; BisectBatchOnFunctionError=true; on-failure SQS/SNS; ReportBatchItemFailures
6 Reads throttled with several consumers Shared 2 MB/s split across classic consumers ReadProvisionedThroughputExceeded > 0; GetRecords throttles Register enhanced fan-out consumers (2 MB/s each); reduce GetRecords frequency
7 PutRecords “succeeds” but data missing Partial failure ignored (FailedRecordCount > 0) Inspect the PutRecords response Records[].ErrorCode Resend only failed records with exponential backoff + jitter
8 Firehose not writing to S3 IAM role lacks s3:PutObject/glue:GetTable, or stream not ACTIVE Firehose console → Monitoring; CloudWatch Logs /aws/kinesisfirehose/<name> Grant S3 + Glue perms on the Firehose role; wait for stream ACTIVE
9 Data arrives but minutes late Buffer interval too long / low volume never hits size Firehose config BufferingHints; object timestamps Lower IntervalInSeconds (min 60); accept small-file trade-off; compact later
10 Parquet conversion fails; records in errors/ Glue table schema mismatch (new field or type drift) S3 errors/format-conversion-failed/; Firehose error logs Align Glue table (add columns, additive); permissive JSON SerDe; backfill from errors/
11 Transform silently drops records / batch fails Lambda returned fewer records than received, or timed out Firehose processing-failed/; Lambda duration near timeout Return every record with Ok/Dropped/ProcessingFailed; raise timeout; keep idempotent
12 Duplicate records downstream At-least-once + producer/consumer retries Duplicate sequence/business keys in the sink Idempotent writes (dedup on a business key); dedup window in the consumer
13 Athena returns 0 rows though objects exist Partition not registered / wrong location SHOW PARTITIONS db.table; check S3 prefix ALTER TABLE ADD PARTITION / MSCK REPAIR; or partition projection
14 Athena scans TB and costs spike Raw JSON, no partition pruning, or SELECT * Athena “Data scanned” in query stats Parquet conversion on; partition + prune (WHERE dt=); select only needed columns
15 Order broken after scaling Read across shards / didn’t drain parent shard on reshard Compare sequence order across ShardIds Rely on per-key order only; use KCL/Lambda ESM (they respect parent→child)

Exception & status reference

Error / status Where it appears Likely cause Fix
ProvisionedThroughputExceededException PutRecord(s), GetRecords Shard cap or hot shard Batch/aggregate, backoff, more shards, on-demand
ExpiredIteratorException GetRecords Shard iterator older than 5 min Get a fresh iterator; poll more often
ResourceNotFoundException Any API Wrong stream/consumer name or region Verify name + region; wait for ACTIVE
ResourceInUseException CreateStream, resharding Stream busy (updating/resharding) Wait until ACTIVE; serialize operations
LimitExceededException Resharding, EFO register Too many reshards/24 h or >20 EFO consumers Slow down; request quota increase
KMSAccessDeniedException Encrypted stream ops Role can’t use the KMS key Grant kms:Decrypt/GenerateDataKey on the CMK
AccessDeniedException (Firehose logs) Delivery Firehose role missing S3/Glue perms Fix the delivery-role policy
Firehose format-conversion-failed S3 errors/ prefix Glue schema mismatch / bad JSON Align Glue table; permissive deserializer
Firehose processing-failed S3 processing-failed/ Transform returned failure/timeout Fix transform contract; raise timeout

The three nastiest failures, in prose

The poison record that wedges a shard. Because ordering is per shard and a Lambda ESM retries a failing batch before advancing, one record your function can’t parse — with the ESM defaults of infinite retries and bisect off — blocks every record behind it on that shard, forever. IteratorAge climbs on that one shard while the others are flat, and if it crosses your retention window you lose data. The fix is a bundle: finite MaximumRetryAttempts, BisectBatchOnFunctionError to isolate the bad record, an on-failure SQS/SNS destination, and ideally ReportBatchItemFailures so the function returns only the sequence numbers that failed and the good ones advance.

Schema drift in Parquet conversion. Firehose Parquet conversion validates every record against the Glue table. The day a producer adds a field or a number arrives as a string, conversion fails for those records and — if you did not configure error output/backup — they vanish. Always set an ErrorOutputPrefix, keep the JSON deserializer permissive, evolve the Glue schema additively (new columns are backward-compatible; renames and type changes are not), and treat the errors/ prefix as an alarmable recoverable queue, not a graveyard.

The transform that “cleans” data by dropping it. New engineers write a Firehose transform that filters records by simply not returning the ones they don’t want. Firehose counts records in versus out; a mismatch fails the batch. To drop a record you must still return it, tagged result: "Dropped". And because Firehose can retry a transform, any side effect (a DB write, an external call) can happen twice — keep transforms pure and idempotent.

Best practices

Security notes

Control KDS Firehose
Encryption at rest SSE with AWS-managed or customer KMS key S3-SSE / SSE-KMS at the destination; SSE-KMS for Direct PUT source
Encryption in transit TLS to the API TLS to the destination
Producer authz kinesis:PutRecord(s) on the stream ARN firehose:PutRecord(Batch) on the delivery-stream ARN
Consumer authz kinesis:GetRecords, GetShardIterator, DescribeStream, SubscribeToShard (EFO) Delivery role reads source, invokes transform, writes destination
Private connectivity Interface VPC endpoint (PrivateLink) for Kinesis VPC delivery to OpenSearch/Redshift in-VPC
Least privilege Scope actions to the specific stream/consumer ARN Scope the Firehose role to exactly the bucket prefix, Glue table, Lambda

A minimal Firehose delivery role policy for the lab (scope tighter in production):

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow",
      "Action": ["kinesis:DescribeStream","kinesis:GetShardIterator","kinesis:GetRecords","kinesis:ListShards"],
      "Resource": "arn:aws:kinesis:us-east-1:ACCT:stream/orders-stream" },
    { "Effect": "Allow",
      "Action": ["s3:AbortMultipartUpload","s3:GetBucketLocation","s3:GetObject","s3:ListBucket","s3:ListBucketMultipartUploads","s3:PutObject"],
      "Resource": ["arn:aws:s3:::pulsecart-lab-lake-ACCT","arn:aws:s3:::pulsecart-lab-lake-ACCT/*"] },
    { "Effect": "Allow", "Action": ["glue:GetTable","glue:GetTableVersion","glue:GetTableVersions"],
      "Resource": "*" },
    { "Effect": "Allow", "Action": ["lambda:InvokeFunction"],
      "Resource": "arn:aws:lambda:us-east-1:ACCT:function:fh-transform:*" }
  ]
}

Key rules: encrypt streams that carry PII with a customer-managed KMS key and grant consumers kms:Decrypt explicitly; put a VPC interface endpoint in front of Kinesis so producers/consumers never traverse the public internet; and scope every producer/consumer/delivery policy to a specific ARN, never kinesis:* on *.

Cost & sizing

Kinesis is not free even when idle — a provisioned shard and an on-demand stream both bill by the hour — so cost awareness is part of the design, not an afterthought. Figures below are representative us-east-1 list prices for illustration; always confirm on the current pricing pages as they change.

KDS provisioned driver Representative price Notes
Shard-hour ~$0.015 / shard-hour ~$10.80/shard/month before data
PUT payload units ~$0.014 / million (25 KB each) A 60 KB record = 3 units
Extended retention (≤7 d) Extra per shard-hour On top of base shard-hour
Long-term retention (>7 d) Per GB-month + retrieval Reads > 24 h charged to retrieve
Enhanced fan-out Per consumer-shard-hour + per GB retrieved Multiplies with consumers × shards
KDS on-demand driver Representative price Notes
Data ingested Per GB written Plus a small stream-hour
Data retrieved Per GB read Fan-out reads add up
Stream-hour Per hour the stream exists Bills even at zero traffic
Firehose driver Representative price Notes
Data ingested ~$0.029 / GB (first 500 TB/mo, Direct PUT/S3) Volume tiers lower it
Format conversion (Parquet/ORC) Extra per GB processed Pays for itself in Athena savings
Dynamic partitioning Extra per GB + per partitioned object Weigh vs. post-hoc compaction
VPC delivery Extra per hour + per GB Only for in-VPC destinations
Worked example (per month) Assumption Cost
Ingest volume ~1 TB/month clickstream
KDS on-demand ingest ~1,024 GB written Dominant KDS line
Firehose delivery ~1,024 GB ~$30 (≈ ₹2,500) at ~$0.029/GB
Firehose Parquet conversion ~1,024 GB Extra per-GB, offset by Athena savings
Athena (Parquet + partitions) Scan ~50 GB of relevant partitions ~$0.25 (≈ ₹21) at $5/TB
Athena (if raw JSON, no partitions) Scan ~1,000 GB ~$5 (≈ ₹420) — 20× more

Cost levers, in order of impact: Parquet + partition pruning is the biggest Athena saver (10–100× fewer bytes scanned); buffer sizing trades latency for fewer/larger objects (fewer S3 PUTs, better scans); enhanced fan-out multiplies by consumers × shards, so enable it only where you need it; and on-demand vs provisioned flips at steady, predictable load — do the shard-hour math before committing to provisioned. The free tier does not meaningfully cover Kinesis, so the teardown at the end of the lab is not optional.

Interview & exam questions

1. What is a shard, and what are its limits? (SAA-C03/DVA-C02) A shard is the unit of capacity and ordering in a Data Stream: 1 MB/s or 1,000 records/s ingest, and 2 MB/s egress (shared) or 2 MB/s per consumer with enhanced fan-out. Records within a shard are strictly ordered by sequence number; ordering does not hold across shards.

2. How does a partition key relate to shards, and what is a hot shard? The partition key is MD5-hashed into a 128-bit space and each shard owns a slice, so the same key always maps to the same shard. A hot shard is when skewed keys concentrate traffic on one shard, throttling it at 1 MB/s while others idle — fixed with higher-cardinality keys, write sharding, splitting, or on-demand.

3. When would you choose Firehose over Data Streams? When the job is managed delivery to a store (S3/Redshift/OpenSearch/Splunk/Iceberg) with no consumers to write and no need for replay or sub-second latency. Firehose has no shards, buffers, transforms, converts to Parquet, and scales itself — but it delivers then forgets.

4. KDS vs SQS? SQS is a queue: one logical consumer group, messages deleted on acknowledgment, no replay, near-unlimited throughput, ~ms latency. KDS is a log: many independent consumers, replay for 24 h–365 d, per-shard ordering. Choose SQS to decouple work, KDS when multiple consumers need the data or you might reprocess.

5. What is enhanced fan-out and when is it worth it? A registered consumer with a dedicated 2 MB/s per shard delivered via HTTP/2 push (SubscribeToShard), ~70 ms latency, up to 20 per stream. Worth it when multiple consumers starve on the shared 2 MB/s or you need low latency; it costs per consumer-shard-hour, so avoid it for a single consumer.

6. What does IteratorAge tell you? It is the age of the last record a consumer read — the lag metric. A rising IteratorAge means the consumer is falling behind; if it approaches the retention period you will lose data. It is the first alarm to set on any KDS consumer.

7. Explain the Firehose transform contract. Firehose invokes a Lambda per buffered batch; the function must return every input record with its recordId, a result of Ok/Dropped/ProcessingFailed, and base64 data. Returning fewer records than received fails the batch; transforms must be idempotent because they can be retried.

8. What does Parquet conversion need, and how does it fail? It needs a Glue Data Catalog table describing the columns and types; Firehose deserializes JSON and serializes Parquet/ORC against it. It fails on schema drift (unknown field, type mismatch), routing those records to the errors/ prefix — mitigated by additive schema evolution and a permissive deserializer.

9. How do you handle a poison record in a Lambda KDS consumer? Because ordering is per shard, a failing record with default infinite retries blocks the shard. Set finite MaximumRetryAttempts, enable BisectBatchOnFunctionError, add an on-failure destination, and implement ReportBatchItemFailures so only failed sequence numbers retry.

10. On-demand vs provisioned capacity mode? Provisioned means you set and reshard the shard count and pay per shard-hour; on-demand auto-scales to 2× the last 30-day peak and bills per GB. On-demand removes hot-shard toil for spiky/unknown traffic; provisioned is cheaper at steady, predictable load.

11. Where does Amazon MSK fit versus KDS? MSK is managed Apache Kafka — choose it when you need the Kafka ecosystem (Kafka Connect, Streams API, Schema Registry, existing Kafka apps). KDS is simpler and fully serverless (especially on-demand) but is not Kafka-API compatible.

12. How do you build a replayable stream that also lands a Parquet lake? Use KDS as the source (replay + multiple real-time consumers) and attach a Firehose as one consumer that transforms, converts to Parquet against a Glue table, dynamically partitions, and writes S3 — one stream serving real-time consumers and the analytics lake at once.

Quick check

  1. Your producer gets ProvisionedThroughputExceededException even though total traffic is under the stream’s capacity. What is the most likely cause and the quickest fix?
  2. You have three teams consuming one stream and they are throttling each other’s reads. What feature isolates them?
  3. A Firehose transform Lambda returns only the records it wants to keep and the batch fails. Why, and what is the correct way to drop a record?
  4. Athena queries over your Firehose-written data scan terabytes and cost a fortune. Name two configuration changes that cut the bill.
  5. Which single CloudWatch metric best tells you a consumer is falling behind, and what happens if it exceeds the retention period?

Answers

  1. A hot shard from partition-key skew — traffic is uneven, so one shard is capped while others idle. Quickest fix: switch to on-demand (or use a higher-cardinality/composite key and/or split the shard).
  2. Enhanced fan-out — register each consumer for a dedicated 2 MB/s per shard via HTTP/2 push, removing contention on the shared 2 MB/s.
  3. Firehose requires count in == count out; a missing record fails the batch. To drop a record, still return it with result: "Dropped" (and ProcessingFailed for bad records so they go to the backup prefix).
  4. Enable Parquet format conversion (columnar) and partition the data (Firehose dynamic partitioning + WHERE dt= pruning), plus select only needed columns — together they cut bytes scanned 10–100×.
  5. GetRecords.IteratorAgeMilliseconds (consumer lag). If it exceeds the retention period, unread records expire and are lost before the consumer reads them.

Glossary

Term Definition
Shard The atomic unit of a Data Stream: 1 MB/s or 1,000 rec/s in, 2 MB/s out, and the boundary of ordering.
Partition key A string whose MD5 hash maps a record to a shard; same key → same shard → ordered.
Sequence number A unique, strictly increasing ID assigned per record per shard; the ordering token.
Shard iterator A cursor for reading a shard from a given position; expires after 5 minutes.
Retention period How long records remain readable: 24 h default, up to 365 days; the replay window.
On-demand mode Capacity mode where AWS auto-scales shards; billed per GB; removes hot-shard toil.
Enhanced fan-out (EFO) A registered consumer with dedicated 2 MB/s per shard via HTTP/2 push, ~70 ms latency.
KPL Kinesis Producer Library — aggregates and batches records for high throughput.
KCL Kinesis Client Library — consumer framework with DynamoDB-based checkpointing and load balancing.
Event source mapping (ESM) The Lambda integration that polls a stream and invokes your function with batches.
IteratorAge The age of the last record a consumer read — the lag/backpressure metric.
Resharding Splitting or merging shards (or UpdateShardCount) to change provisioned capacity.
Data Firehose Fully managed delivery to S3/Redshift/OpenSearch/Splunk/Iceberg/HTTP; no shards.
Buffering hints Firehose size/interval thresholds; it flushes on whichever is hit first.
Format conversion Firehose JSON→Parquet/ORC using a Glue table for the schema.
Dynamic partitioning Firehose routing records to S3 prefixes derived from their content (jq/Lambda).

Next steps

AWSKinesis Data StreamsData FirehoseStreamingShardsEnhanced Fan-OutParquetAthena
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading