This page enables a stream, wires a Lambda trigger (the common path), and reads the stream directly with the low-level Streams client (the custom path). Code is shown in both SDKs.
## Recipe
> Quick-reference recipe card - copy-paste ready.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
import boto3
ddb = boto3.client("dynamodb", region_name="us-east-1")
# Enable a stream carrying both the old and new item image on each change.
ddb.update_table(
TableName="Orders",
StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"},
)
```
```typescript
// --- TypeScript (AWS SDK v3) ---
import { DynamoDBClient, UpdateTableCommand } from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient({ region: "us-east-1" });
// Enable a stream carrying both the old and new item image on each change.
await ddb.send(new UpdateTableCommand({
TableName: "Orders",
StreamSpecification: { StreamEnabled: true, StreamViewType: "NEW_AND_OLD_IMAGES" },
}));
```
</CodeGroup>
**When to reach for this:**
- Reacting to writes: send a welcome email when a user item is inserted.
- Keeping a derived store in sync: mirror changes into OpenSearch or a cache.
- Maintaining aggregates: increment a counter item when children change.
- Auditing and replication: log every before/after image, or replicate cross-region.
## Working Example
The typical consumer is a Lambda function attached to the stream. Below: enable the stream, and a Lambda handler that processes a batch of change records, branching on the event type and reading the new and old images.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
# Lambda handler for a DynamoDB Streams event source. Deserializer turns
# the {"S": ...} stream image into plain Python values.
from boto3.dynamodb.types import TypeDeserializer
_deser = TypeDeserializer()
def _plain(image):
return {k: _deser.deserialize(v) for k, v in (image or {}).items()}
def handler(event, context):
for record in event["Records"]:
name = record["eventName"] # INSERT | MODIFY | REMOVE
ddb = record["dynamodb"]
new = _plain(ddb.get("NewImage"))
old = _plain(ddb.get("OldImage"))
if name == "INSERT":
print("created", new.get("orderId"))
elif name == "MODIFY":
print("changed total", old.get("total"), "->", new.get("total"))
elif name == "REMOVE":
print("deleted", old.get("orderId"))
return {"batchItemFailures": []} # report partial failures here
```
```typescript
// --- TypeScript (AWS SDK v3) ---
// Lambda handler for a DynamoDB Streams event source. unmarshall converts
// the {"S": ...} stream image into a plain object.
import { unmarshall } from "@aws-sdk/util-dynamodb";
import type { DynamoDBStreamEvent, DynamoDBBatchResponse } from "aws-lambda";
export async function handler(event: DynamoDBStreamEvent): Promise<DynamoDBBatchResponse> {
for (const record of event.Records) {
const name = record.eventName; // INSERT | MODIFY | REMOVE
const img = record.dynamodb ?? {};
const next = img.NewImage ? unmarshall(img.NewImage as any) : undefined;
const prev = img.OldImage ? unmarshall(img.OldImage as any) : undefined;
if (name === "INSERT") console.log("created", next?.orderId);
else if (name === "MODIFY") console.log("changed total", prev?.total, "->", next?.total);
else if (name === "REMOVE") console.log("deleted", prev?.orderId);
}
return { batchItemFailures: [] }; // report partial failures here
}
```
</CodeGroup>
You attach this function with an event-source mapping (in the console, IaC, or `CreateEventSourceMapping`), pointing at the table's stream ARN with a `StartingPosition` of `LATEST` or `TRIM_HORIZON`. Lambda then polls the shards, batches records, and invokes your handler.
**What this demonstrates:**
- Each record carries an `eventName` (`INSERT`, `MODIFY`, `REMOVE`) and, per your view type, the new and old images.
- Stream images use DynamoDB's typed wire format, so deserialize them with `TypeDeserializer` / `unmarshall`.
- Returning `batchItemFailures` lets Lambda retry only the records that failed, not the whole batch.
- The handler is where change-data-capture logic lives: index, notify, aggregate, replicate.
## Deep Dive
### Stream View Types
`StreamViewType` decides what each record contains:
- `KEYS_ONLY` - only the key attributes of the changed item.
- `NEW_IMAGE` - the item as it looks after the change.
- `OLD_IMAGE` - the item as it looked before the change.
- `NEW_AND_OLD_IMAGES` - both, which is what you want for diffing (for example, "total went from X to Y").
You choose it when enabling the stream, and changing it later requires disabling and re-enabling the stream.
### Two Ways to Consume
**Lambda event-source mapping** is the default and easiest. Lambda manages shard discovery, checkpointing, batching, parallelism, and retries. You tune `BatchSize`, `MaximumBatchingWindowInSeconds`, `ParallelizationFactor`, a `FilterCriteria` to skip records you do not care about, and an on-failure destination for a dead-letter queue.
**Direct consumption** uses `@aws-sdk/client-dynamodb-streams` (boto3's `dynamodbstreams` client): `DescribeStream` to list shards, `GetShardIterator` to get a cursor, then `GetRecords` in a loop. You manage checkpoints and shard splits yourself. Use this only when Lambda does not fit.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
streams = boto3.client("dynamodbstreams", region_name="us-east-1")
desc = streams.describe_stream(StreamArn=stream_arn)["StreamDescription"]
shard_id = desc["Shards"][0]["ShardId"]
it = streams.get_shard_iterator(
StreamArn=stream_arn, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON",
)["ShardIterator"]
records = streams.get_records(ShardIterator=it)["Records"]
print(len(records), "records")
```
```typescript
// --- TypeScript (AWS SDK v3) ---
import {
DynamoDBStreamsClient, DescribeStreamCommand, GetShardIteratorCommand, GetRecordsCommand,
} from "@aws-sdk/client-dynamodb-streams";
const streams = new DynamoDBStreamsClient({ region: "us-east-1" });
const desc = await streams.send(new DescribeStreamCommand({ StreamArn: streamArn }));
const shardId = desc.StreamDescription!.Shards![0].ShardId;
const { ShardIterator } = await streams.send(new GetShardIteratorCommand({
StreamArn: streamArn, ShardId: shardId, ShardIteratorType: "TRIM_HORIZON",
}));
const { Records } = await streams.send(new GetRecordsCommand({ ShardIterator }));
console.log(Records?.length, "records");
```
</CodeGroup>
### Ordering, Retention, and Delivery
Records for a given partition key arrive in the order the changes happened. Across partition keys there is no global order. The stream retains records for **24 hours**. Delivery is **at-least-once**, so a record can be seen more than once - make your processing idempotent (for example, upsert into the target store keyed by item id).
### Streams vs Kinesis Data Streams
DynamoDB also supports emitting change records to a **Kinesis Data Stream** instead of (or as well as) a native stream. Kinesis gives longer retention (up to a year), more consumers via enhanced fan-out, and integration with Kinesis analytics, at the cost of managing another service. Native Streams are simpler and free to read from a single Lambda; reach for Kinesis when you need multiple independent consumers or longer retention.
## Gotchas
- **Non-idempotent handlers.** At-least-once delivery means duplicate records. **Fix:** make processing idempotent, keyed by the item id or a sequence number.
- **Wrong view type for diffs.** `NEW_IMAGE` alone cannot tell you what changed. **Fix:** use `NEW_AND_OLD_IMAGES` when you need before/after.
- **Failing the whole batch on one bad record.** A thrown error retries the entire batch. **Fix:** return `batchItemFailures` to retry only the failed records.
- **Ignoring the 24-hour retention.** A stalled consumer loses data after a day. **Fix:** monitor iterator age and alarm before it approaches 24 hours.
- **Forgetting stream images are typed.** Records carry `{"S": ...}` values, not plain objects. **Fix:** deserialize with `TypeDeserializer` / `unmarshall`.
- **Expecting global ordering.** Order holds per partition key only. **Fix:** design so causally related changes share a partition key.
## Alternatives
| Alternative | Use When | Don't Use When |
|-------------|----------|----------------|
| Streams + Lambda (this page) | Simple, serverless reactions to changes | You need many independent consumers |
| Streams + custom consumer | Full control over shard iteration | Lambda's managed polling would do |
| Kinesis Data Streams for DynamoDB | Longer retention, multiple consumers | A single Lambda consumer suffices |
| EventBridge Pipes | Filter/transform/route with no code | You need arbitrary compute per record |
| Application dual-write | You control every write path | Reliability matters (dual-writes can diverge) |
## FAQs
<details>
<summary>What is a DynamoDB Stream?</summary>
An ordered, time-ordered log of item-level changes to a table. Every insert, update, and delete produces a record you can consume to trigger downstream work like indexing, notifications, or replication.
</details>
<details>
<summary>What does StreamViewType control?</summary>
What each record contains: KEYS_ONLY (just keys), NEW_IMAGE (item after the change), OLD_IMAGE (item before), or NEW_AND_OLD_IMAGES (both). Use NEW_AND_OLD_IMAGES when you need to diff before and after.
</details>
<details>
<summary>How do I trigger a Lambda on changes?</summary>
Create an event-source mapping from the table's stream ARN to your function, with a StartingPosition of LATEST or TRIM_HORIZON. Lambda then polls the stream, batches records, and invokes your handler automatically.
</details>
<details>
<summary>How long are stream records kept?</summary>
24 hours. A consumer that stalls longer than that permanently loses the records that expire. Monitor the iterator age and alarm well before it reaches the limit.
</details>
<details>
<summary>Is stream delivery exactly-once?</summary>
No, it is at-least-once, so a record can be delivered more than once. Make your processing idempotent - for example upsert into the target keyed by item id - so duplicates do not cause double effects.
</details>
<details>
<summary>Are stream records ordered?</summary>
Records for the same partition key arrive in the order the changes occurred. There is no ordering guarantee across different partition keys, so keep causally related changes on the same partition key.
</details>
<details>
<summary>How do I handle a failing record in a batch?</summary>
Return a batchItemFailures list with the failed sequence numbers instead of throwing. Lambda then retries only those records rather than the entire batch, avoiding reprocessing successful items.
</details>
<details>
<summary>When should I use Kinesis instead of native Streams?</summary>
When you need longer retention (up to a year), multiple independent consumers, or enhanced fan-out and Kinesis analytics. For a single serverless consumer with 24-hour retention, native Streams are simpler.
</details>
<details>
<summary>Why are the images in typed format?</summary>
Stream records carry DynamoDB's low-level wire format ({"S": ...}, {"N": ...}). Convert them to plain values with boto3's TypeDeserializer or the @aws-sdk/util-dynamodb unmarshall helper before using them.
</details>
## Related
- [DynamoDB Deep Dive Basics](./dynamodb-deep-dive-basics.md) - the writes that produce stream records.
- [Transactions & Conditional Writes](./transactions-and-conditional-writes.md) - transactional writes also appear on the stream.
- [Single-Table Design Patterns](./single-table-design-patterns.md) - keeping aggregates in sync via change capture.
- [AWS Lambda via SDK: Your First Function](../core-services/aws-lambda-via-sdk-your-first-function.md) - the function that consumes the stream.
> **Stack versions:** This page was written for **boto3 1.43.x** (Python 3.10+) and the **AWS SDK for JavaScript v3** (Node.js 18+).
<span class="text-xs italic text-foreground/35" aria-hidden="true">636f64656775696465732e696f7c6367696f313135347c323032363037</span>