Why This Matters
DynamoDB Streams is a native change data capture (CDC) feature built directly into Amazon DynamoDB. Unlike external CDC solutions like Debezium that require additional infrastructure, DynamoDB Streams provides zero-overhead, real-time change tracking that is fully managed by AWS. For data engineers building real-time pipelines, this means you can capture every INSERT, MODIFY, and REMOVE event on a DynamoDB table and route it to Lambda, Kinesis, or other AWS services without any additional infrastructure.
Streams are retained for 24 hours, records are ordered per partition key, and throughput automatically scales with your table's capacity. The feature charges only on Read Request Units consumed when reading stream records, making it cost-effective for both low-volume operational tables and high-throughput analytics workloads.
Architecture Overview
Real-World Project Structure
dynamodb-streams-pipeline/
āāā infrastructure/
ā āāā cdk/
ā ā āāā app.py
ā ā āāā stacks/
ā ā ā āāā dynamodb_stack.py
ā ā ā āāā lambda_triggers_stack.py
ā ā ā āāā kinesis_stack.py
ā ā ā āāā monitoring_stack.py
ā ā āāā cdk.json
ā āāā terraform/
ā āāā main.tf
ā āāā dynamodb.tf
ā āāā lambda_triggers.tf
ā āāā variables.tf
āāā lambda_functions/
ā āāā stream_processor/
ā ā āāā app.py
ā ā āāā requirements.txt
ā ā āāā tests/
ā āāā firehose_transform/
ā ā āāā app.py
ā ā āāā requirements.txt
ā āāā index_updater/
ā āāā app.py
ā āāā requirements.txt
āāā kinesis/
ā āāā stream_config.json
ā āāā firehose_config.json
āāā tests/
ā āāā unit/
ā āāā integration/
ā āāā test_stream_processor.py
āāā monitoring/
ā āāā dashboards/
ā āāā alarms/
āāā README.md
Stream Record View Types
DynamoDB offers four view types that control what data each stream record contains:
| View Type | Data Included | Use Case | Storage Cost |
|---|---|---|---|
KEYS_ONLY | Primary key only | Simple notifications, filtering | Lowest |
NEW_IMAGE | Item after modification | Replication, indexing | Medium |
OLD_IMAGE | Item before modification | Audit, cleanup | Medium |
NEW_AND_OLD_IMAGES | Before and after | Diff detection, CDC | Highest |
Lambda Trigger Integration
import json
import boto3
from decimal import Decimal
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('ProcessedItems')
def lambda_handler(event, context):
"""
Process DynamoDB Stream records from table changes.
"""
batch_count = 0
error_count = 0
for record in event['Records']:
try:
event_name = record['eventName']
dynamodb_data = record['dynamodb']
seq_num = dynamodb_data['SequenceNumber']
if event_name == 'INSERT':
new_image = deserialize(dynamodb_data['NewImage'])
process_insert(new_image, seq_num)
elif event_name == 'MODIFY':
old_image = deserialize(dynamodb_data.get('OldImage', {}))
new_image = deserialize(dynamodb_data['NewImage'])
process_modify(old_image, new_image, seq_num)
elif event_name == 'REMOVE':
old_image = deserialize(dynamodb_data['OldImage'])
process_remove(old_image, seq_num)
batch_count += 1
except Exception as e:
print(f"Error processing record: {str(e)}")
error_count += 1
print(f"Processed: {batch_count}, Errors: {error_count}")
return {'processed': batch_count, 'errors': error_count}
def deserialize(image):
"""Convert DynamoDB formatted JSON to regular Python dict."""
return {k: list(v.values())[0] for k, v in image.items()}
def process_insert(new_image, seq_num):
"""Handle new item creation - index in OpenSearch."""
item_id = new_image.get('id')
print(f"INSERT: item={item_id}, seq={seq_num}")
# Index in OpenSearch for full-text search
# Notify downstream systems
def process_modify(old_image, new_image, seq_num):
"""Handle item updates - detect specific field changes."""
item_id = new_image.get('id')
changed_fields = [
k for k in new_image
if k in old_image and old_image[k] != new_image[k]
]
print(f"MODIFY: item={item_id}, changed={changed_fields}, seq={seq_num}")
# Update search index for changed fields only
def process_remove(old_image, seq_num):
"""Handle item deletion - cleanup downstream resources."""
item_id = old_image.get('id')
print(f"REMOVE: item={item_id}, seq={seq_num}")
# Remove from search index
# Cleanup related resources
Idempotent Processing
Since DynamoDB Streams provides at-least-once delivery, consumers must be idempotent:
import time
import boto3
dynamodb = boto3.resource('dynamodb')
processed_table = dynamodb.Table('ProcessedEventIds')
def process_record_idempotent(record, handler):
"""Process a stream record with idempotency guarantees."""
event_id = record['eventID']
# Check if already processed
try:
response = processed_table.get_item(Key={'event_id': event_id})
if 'Item' in response:
print(f"Skipping duplicate event: {event_id}")
return True
except Exception as e:
print(f"Error checking idempotency: {str(e)}")
# Process the record
handler(record)
# Mark as processed with conditional write
try:
processed_table.put_item(
Item={
'event_id': event_id,
'timestamp': int(time.time()),
'ttl': int(time.time()) + 86400 * 7 # 7 day TTL
},
ConditionExpression='attribute_not_exists(event_id)'
)
except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
print(f"Concurrent processing detected for: {event_id}")
return True
return True
Performance Considerations
| Metric | Value | Recommendation |
|---|---|---|
| Record retention | 24 hours | Use Kinesis for longer retention |
| Max record size | 1 MB | Design items to stay under limit |
| Read throughput | Matches table capacity | Scale table for stream needs |
| Shard count | Auto-managed | No manual intervention needed |
| Lambda batch size | Up to 1,000 records | Start with 100, tune based on latency |
| Processing latency | Sub-second | Monitor IteratorAgeMilliseconds |
Security Considerations
- IAM Execution Roles: Grant minimal permissions for stream read and target service access.
- VPC Endpoints: Use VPC endpoints for DynamoDB and Lambda to keep traffic off public internet.
- Encryption: DynamoDB Streams encrypts data at rest using AWS-owned keys by default.
- CloudTrail: Log all DynamoDB API calls including stream enable/disable operations.
- Lambda Dead Letter Queue: Configure DLQ for failed stream records to prevent data loss.
- Reserved Concurrent Executions: Limit Lambda concurrency to prevent downstream system overload.
- Stream Access Control: Use IAM policies to control who can enable/disable streams on tables.
Advanced: DynamoDB Streams with KCL Consumer
For high-throughput scenarios beyond Lambda, use the Kinesis Client Library (KCL) for custom consumers:
from amazon_kclpy import processor
from amazon_kclpy import kcl
import json
import boto3
class StreamRecordProcessor(processor.RecordProcessorBase):
"""
Custom DynamoDB Streams consumer using KCL.
"""
def __init__(self):
self.batch_size = 100
self.buffer = []
def initialize(self, shard_id):
print(f"Processing shard: {shard_id}")
def process_records(self, records, checkpointer):
for record in records:
event_name = record['dynamodb']['NewImage'] and 'INSERT' or 'REMOVE'
self.buffer.append({
'event_id': record['eventID'],
'event_name': event_name,
'sequence': record['dynamodb']['SequenceNumber']
})
if len(self.buffer) >= self.batch_size:
self.flush_buffer()
checkpointer.checkpoint(records[-1]['sequenceNumber'])
def flush_buffer(self):
# Process buffered records in bulk
print(f"Processing batch of {len(self.buffer)} records")
self.buffer = []
def shutdown(self, checkpointer, reason):
if reason == 'TERMINATE':
self.flush_buffer()
checkpointer.checkpoint()
Advanced: Lambda Event Source Mapping Configuration
import boto3
import json
lambda_client = boto3.client('lambda')
def configure_stream_trigger(function_name, stream_arn):
"""
Configure Lambda event source mapping for DynamoDB Streams.
"""
response = lambda_client.create_event_source_mapping(
FunctionName=function_name,
EventSourceArn=stream_arn,
StartingPosition='LATEST',
BatchSize=100,
MaximumBatchingWindowInSeconds=60,
BisectBatchOnFunctionError=True,
MaximumRetryAttempts=3,
MaximumRecordAgeInSeconds=86400, # 24 hours
TumblingWindowInSeconds=300, # 5-minute windows
DestinationConfig={
'OnFailure': {
'Destination': 'arn:aws:sqs:us-east-1:123456789012:failed-stream-records'
}
},
FunctionResponseTypes=['ReportBatchItemFailures']
)
print(f"Event source mapping created: {response['UUID']}")
return response['UUID']
Interview Questions & Answers
Q1: What are DynamoDB Streams and when should you use them?
Answer: DynamoDB Streams is a managed CDC feature that captures item-level changes (INSERT, MODIFY, REMOVE) in a DynamoDB table with 24-hour retention. Use streams when you need real-time event-driven processing, data replication, search index updates, or analytics pipelines that react to DynamoDB changes. They are ideal when you want CDC without external tools like Debezium, as the feature is native to DynamoDB with zero additional infrastructure. Streams are particularly powerful for maintaining denormalized views, updating materialized views, and triggering downstream workflows when data changes.
Q2: What are the key differences between DynamoDB Streams and Kinesis Data Streams?
Answer: DynamoDB Streams is per-table, has 24-hour retention, is fully managed by DynamoDB, and captures only DynamoDB changes. Kinesis Data Streams is an independent service with configurable retention (up to 365 days), higher throughput potential, supports multiple producer types beyond DynamoDB, and provides enhanced fan-out. Use DynamoDB Streams for simple, table-specific CDC with minimal operational overhead. Use Kinesis when you need longer retention, higher throughput, or to combine DynamoDB data with other streaming sources in a single pipeline.
Q3: How do you ensure idempotent processing of DynamoDB Stream records?
Answer: Use the eventID field as a unique identifier and track processed events in a deduplication store (DynamoDB table with conditional writes, Redis, etc.). Before processing, check if the event has already been processed. Use conditional writes (ConditionExpression='attribute_not_exists(event_id)') to atomically mark events as processed. Implement TTL on the deduplication records to automatically clean up old entries. This prevents duplicate processing during retries or shard rebalancing, which can cause at-least-once delivery.
Q4: What happens when a DynamoDB Stream record exceeds 1 MB?
Answer: DynamoDB Streams truncates stream records that exceed 1 MB. The truncated record includes a SizeEstimateBytes field indicating the original size. Consumers should detect truncation by checking if the record is smaller than expected, then fetch the complete item directly from DynamoDB using the primary key. Design item access patterns to minimize large items, or implement a fallback read pattern for truncated records. For items that frequently exceed 1 MB, consider splitting them into smaller related items.
Q5: How do DynamoDB Global Tables relate to DynamoDB Streams?
Answer: DynamoDB Global Tables use DynamoDB Streams under the hood for cross-region replication. When you enable Global Tables, DynamoDB automatically creates streams and replicates changes to all participating regions with sub-second latency. This provides multi-active replication where writes to any region are automatically propagated. Global Tables are a managed implementation of the stream-based CDC pattern, abstracting away the complexity of manual replication, conflict resolution, and shard management.
Q6: Explain the difference between TRIM_HORIZON and LATEST as starting positions.
Answer: TRIM_HORIZON starts reading from the oldest available record in the stream (up to 24 hours old). This is useful for backfill scenarios, reprocessing historical changes, or when you need to process all modifications since the stream was enabled. LATEST starts reading from the most recent record, only processing new changes going forward. Choose TRIM_HORIZON for initial load or replay scenarios. Choose LATEST for steady-state processing where you only care about new changes. Both positions maintain their state across Lambda invocations.
Q7: How do you handle shard splits in DynamoDB Streams consumers?
Answer: When a shard splits, the parent shard is closed and child shards are opened. Consumers should detect shard closure via the ShardIterator becoming null or receiving a specific error. At that point, the consumer should start reading from the child shards using the last sequence number from the parent shard. AWS recommends using the Kinesis Client Library (KCL) or Lambda event source mapping which handles shard management automatically. For custom consumers, implement shard iteration tracking and handle child shard discovery gracefully.
Q8: What monitoring should you set up for DynamoDB Streams?
Answer: Monitor CloudWatch metrics including ReadThrottleEvents (indicates capacity issues), GetRecords.IteratorAgeMilliseconds (consumer lag - growing lag means consumers cannot keep up), and ReadProvisionedThroughputExceeded. Set alarms for iterator age growing beyond acceptable thresholds. Log Lambda invocations, errors, and duration to CloudWatch Logs. Track shard count changes which indicate table scaling. Monitor DLQ depth for failed records. Use CloudTrail to audit stream enable/disable operations. Set up dashboards to visualize end-to-end pipeline health.
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Not implementing idempotency | Duplicate records in downstream systems | Use eventID with conditional writes |
| Ignoring 1 MB limit | Truncated records, missing data | Design smaller items or implement fallback reads |
| No DLQ on Lambda | Failed records are lost | Configure DLQ for all stream consumers |
| Wrong view type | Missing before/after images | Choose NEW_AND_OLD_IMAGES for CDC |
| Not monitoring iterator age | Silent consumer lag | Set CloudWatch alarms on IteratorAgeMilliseconds |
| Hardcoded batch sizes | Performance issues at scale | Start with 100, tune based on metrics |
| No TTL on dedup table | DynamoDB costs grow unbounded | Add TTL for automatic cleanup |
| Assuming global ordering | Out-of-order processing bugs | Design for per-partition-key ordering only |
Advanced: Kinesis Data Firehose Integration
Route DynamoDB Stream records through Kinesis Data Firehose for direct delivery to S3, Redshift, or OpenSearch:
import json
import base64
import boto3
def lambda_handler(event, context):
"""
Transform DynamoDB Stream records for Kinesis Data Firehose delivery.
"""
output = []
for record in event['Records']:
# Decode the DynamoDB stream record
payload = base64.b64decode(record['data']).decode('utf-8')
dynamodb_record = json.loads(payload)
# Transform to target format (Parquet-ready JSON)
transformed = {
'event_id': record['recordId'],
'event_name': dynamodb_record['eventName'],
'timestamp': dynamodb_record['dynamodb']['ApproximateCreationDateTime'],
'table_name': dynamodb_record['eventSourceARN'].split('/')[1],
'keys': dynamodb_record['dynamodb']['Keys'],
'new_image': dynamodb_record['dynamodb'].get('NewImage', {}),
'old_image': dynamodb_record['dynamodb'].get('OldImage', {}),
'sequence_number': dynamodb_record['dynamodb']['SequenceNumber']
}
# Add partition key for Firehose S3 prefixing
transformed['dt'] = __import__('datetime').datetime.fromtimestamp(
transformed['timestamp']
).strftime('%Y-%m-%d')
output.append({
'recordId': record['recordId'],
'result': 'Ok',
'data': base64.b64encode(
json.dumps(transformed).encode('utf-8')
).decode('utf-8')
})
return {'records': output}
Advanced: Cross-Region Replication Pattern
import boto3
import json
dynamodb_client = boto3.client('dynamodb')
def setup_global_tables():
"""
Configure DynamoDB Global Tables for cross-region replication.
"""
# Global Tables use DynamoDB Streams under the hood
# This is the managed CDC pattern for multi-region replication
response = dynamodb_client.create_table(
TableName='orders-global',
KeySchema=[
{'AttributeName': 'order_id', 'KeyType': 'HASH'},
{'AttributeName': 'timestamp', 'KeyType': 'RANGE'}
],
AttributeDefinitions=[
{'AttributeName': 'order_id', 'AttributeType': 'S'},
{'AttributeName': 'timestamp', 'AttributeType': 'N'}
],
BillingMode='PAY_PER_REQUEST',
GlobalSecondaryIndexes=[
{
'IndexName': 'customer-index',
'KeySchema': [
{'AttributeName': 'customer_id', 'KeyType': 'HASH'},
{'AttributeName': 'order_date', 'KeyType': 'RANGE'}
],
'Projection': {'ProjectionType': 'ALL'}
}
],
StreamSpecification={
'StreamEnabled': True,
'StreamViewType': 'NEW_AND_OLD_IMAGES'
}
)
# Enable Global Tables replication
dynamodb_client.update_table(
TableName='orders-global',
ReplicaUpdates=[
{
'Create': {
'RegionName': 'us-west-2',
'KMSMasterKeyId': 'alias/aws/dynamodb'
}
},
{
'Create': {
'RegionName': 'eu-west-1',
'KMSMasterKeyId': 'alias/aws/dynamodb'
}
}
]
)
print("Global Tables configured for multi-region replication")
Advanced: DynamoDB Streams Analytics
-- Query stream records in Athena for analytics
SELECT
eventname,
date_format(
from_unixtime(dynamodb.approximatecreationdatetime),
'%Y-%m-%d %H:00:00'
) as event_hour,
COUNT(*) as event_count,
COUNT(DISTINCT dynamodb.keys['id'].s) as unique_items
FROM dynamodb_streams_table
WHERE dt = '2025-01-15'
GROUP BY 1, 2
ORDER BY event_hour, event_count DESC;