Lambda DynamoDB Streams Trigger Not Firing: the Old Stream ARN, Disabled or Creating Mappings, LATEST vs TRIM_HORIZON, Execution Role Permissions, Filter JSON, No-Change Writes and Stuck Shards
A DynamoDB Streams trigger that is not firing is almost never a Lambda that refuses to run. It is an event source mapping that is reading the wrong stream, is not allowed to read it, is still being created, is filtering every record out, or is stuck behind a batch that keeps failing. Three commands sort it: aws dynamodb describe-table shows the table's LatestStreamArn; aws lambda get-event-source-mapping shows the ARN the mapping actually reads, plus its State and LastProcessingResult; and the function's IteratorAge metric says whether records are arriving and aging. Here is the one that catches people who did everything right: turning a stream off and on creates a new stream with a new ARN, and the mapping keeps pointing at the old one, which goes quiet and then disappears. Two more quiet ones: a filter written in plain JSON matches nothing, because stream records are in DynamoDB's typed format ({"S": "PAID"}, not "PAID"), and a write that changes nothing produces no stream record at all. Below: the diagnosis in order, each cause with its fix, the stuck-shard pattern that looks like "it stopped firing," the parameters that keep one bad record from blocking a shard for a day, and a mapping that works end to end.
The shop's booking table has a stream, and a Lambda on that stream sends the confirmation email whenever a booking's Status becomes PAID. It worked for months. Then Ethan changed the stream from "new image" to "new and old images," so the function could see the previous status, and the emails stopped. The function's logs showed nothing since the change. The stream was on. The trigger in the Lambda console said Enabled. Jake asked the question that solved it: "When you changed the stream, did it get a new name?" It had. Changing the view type means turning the stream off and on, and the new stream had a new ARN with the day's timestamp in it. The trigger was faithfully watching the old stream, which had nothing in it and would be deleted in 24 hours.
New to DynamoDB or Lambda? Our plain-English DynamoDB guide and Lambda guide cover the words this page uses. Commands use the AWS CLI; on Windows Command Prompt, replace the line-ending \ with ^.
How the trigger actually works, in one paragraph
DynamoDB Streams is a 24-hour log of item changes on one table: every insert, modify and remove becomes a stream record with the item's keys and, depending on the view type, the item before and after. Lambda does not get pushed these records. A Lambda resource called an event source mapping polls the stream's shards about four times a second, using your function's execution role to call the stream, gathers records into a batch, and invokes your function with the batch. If the function succeeds, the mapping moves on; if it fails, the mapping retries the same batch and, by default, does not move on until it succeeds or the records expire. So "the trigger is not firing" is a question about the mapping: which stream is it reading, is it allowed to, is it enabled, what is it filtering, and is it stuck.
"So the Lambda is a reader with a library card," Jake said, "and the stream is the newspaper. If the paper changed its name, the reader is standing at the old rack." Ethan admitted that was exactly what had happened.
Three commands that find the cause
- Which stream does the table have right now?
aws dynamodb describe-table --table-name bookings \ --query "Table.{stream: StreamSpecification, arn: LatestStreamArn}"IfStreamSpecificationis missing orStreamEnabledis false, there is no stream and nothing can fire. Note the ARN; the timestamp at the end is the moment the stream was enabled. - Which stream does the mapping read, and what state is it in?
aws lambda list-event-source-mappings --function-name send-confirmation \ --query "EventSourceMappings[].{uuid: UUID, source: EventSourceArn, state: State, why: StateTransitionReason, last: LastProcessingResult, pos: StartingPosition, filter: FilterCriteria}"Thesourcemust match the ARN from step 1 exactly.statemust beEnabled.lastis what happened on the most recent poll:No records processedmeans the mapping is healthy and the stream is empty (or everything was filtered out);OKmeans records were delivered; anything starting withPROBLEMnames the fault.whysays whether a user or Lambda made the last state change; if Lambda disabled your mapping, it did so because it could no longer read the stream. - Are records arriving and aging?
for m in Invocations Errors IteratorAge; do echo "== $m" aws cloudwatch get-metric-statistics --namespace AWS/Lambda --metric-name $m \ --dimensions Name=FunctionName,Value=send-confirmation \ --start-time $(date -u -d '6 hours ago' +%FT%TZ) --end-time $(date -u +%FT%TZ) --period 900 --statistics Sum Maximum \ --query "sort_by(Datapoints,&Timestamp)[].[Timestamp,Sum,Maximum]" --output text; doneNo invocations and noIteratorAgeat all: records are not reaching the mapping (ARN, state, role, filter, or nothing is being written).IteratorAgeclimbing into the millions of milliseconds withErrorsabove zero: the mapping is firing, your function is failing, and the shard is stuck (see stuck shard).
Then make a change that must produce a record, and watch: aws dynamodb put-item with a new key, wait a minute, and read the function's log group. Everything below is what the three commands turn up.
Cause 1: the stream was turned off and on, and the mapping reads the old ARN
A table has at most one stream, and a stream's view type (keys only, new image, old image, both) cannot be edited. To change it you disable the stream and enable a new one, and the new one gets a new ARN with a new timestamp. Terraform, CloudFormation and the console all do this for you when you change the view type, which is why the change feels like an edit. The event source mapping is a separate resource that holds the old ARN. The old stream stays readable for 24 hours with no new records, and then it is gone; the mapping goes quiet first and then Lambda disables it.
The fix is a new mapping on the new ARN (a mapping's EventSourceArn cannot be changed in place), then delete the old one. In order, so you do not lose records between the two:
- Read the table's current
LatestStreamArn. - Create the new mapping on it with
TRIM_HORIZON, so everything written since the new stream was enabled is picked up. - Wait until its
StateisEnabledandLastProcessingResultshowsOKorNo records processed. - Delete the old mapping by UUID; the old stream itself expires on its own within 24 hours.
NEW=$(aws dynamodb describe-table --table-name bookings --query Table.LatestStreamArn --output text) aws lambda create-event-source-mapping --function-name send-confirmation --event-source-arn "$NEW" \ --starting-position TRIM_HORIZON --batch-size 100 aws lambda delete-event-source-mapping --uuid OLD_UUID
If you manage the table and the mapping in the same Terraform or CloudFormation stack and reference the stream ARN as an attribute (aws_dynamodb_table.bookings.stream_arn, !GetAtt Table.StreamArn), the mapping is replaced for you. A hard-coded ARN, or a mapping created by hand in the console, is the version that breaks. Ethan's was created in the console in the spring.
Cause 2: the mapping is Disabled, or still Creating
A mapping has a state: Creating, Enabling, Enabled, Disabling, Disabled, Updating or Deleting. Only Enabled delivers records. Two shapes are common:
- Disabled by you (or by a deployment that set
Enabled: false). Re-enable it; the mapping remembers where it stopped and resumes from there, as long as those records are still within the 24-hour window. - Disabled by Lambda, with
StateTransitionReasonsaying so. Lambda does this when it can no longer read the stream: the stream was deleted or replaced (cause 1), or the role lost its permissions (cause 3). Fix the underlying problem, then re-enable or recreate.
aws lambda update-event-source-mapping --uuid UUID --enabled
aws lambda get-event-source-mapping --uuid UUID --query "{state: State, why: StateTransitionReason, last: LastProcessingResult}"
And Creating or Updating is not instant. Polling "may take several minutes to start" after a create or update, so a test you run thirty seconds after saving can miss. Wait for Enabled, then test. Which leads to the starting position.
Cause 3: StartingPosition LATEST missed the records you were testing with
With LATEST, the mapping starts reading from the newest point in the stream once it is polling. Because creation is eventually consistent, records written during those first minutes can fall before the point the mapping starts from, and are never delivered. If your test is "create the trigger, write an item, see nothing," this is often why. Use TRIM_HORIZON when you create a mapping: it starts at the oldest record still in the stream (up to 24 hours back) and catches up, which also means your function sees everything written since the stream was enabled. If that backlog is unwanted, enable the stream, wait, create the mapping with TRIM_HORIZON, and let the first batches run through.
Cause 4: the execution role cannot read the stream
The mapping reads the stream with the function's execution role, not with your user. The role needs four stream actions, which the managed policy AWSLambdaDynamoDBExecutionRole provides:
aws iam attach-role-policy --role-name send-confirmation-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole
# or the same four actions scoped to one stream, plus logs:
{ "Effect": "Allow",
"Action": ["dynamodb:DescribeStream", "dynamodb:GetRecords", "dynamodb:GetShardIterator", "dynamodb:ListStreams"],
"Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/bookings/stream/*" }
Missing permissions show up in two ways. At creation time the mapping is refused with an InvalidParameterValueException that begins Cannot access stream and lists the actions the role must be able to perform. Later, if a policy change removes them, the mapping stops delivering and Lambda eventually disables it. A role scoped with a Resource of table/bookings (the table ARN) rather than table/bookings/stream/* also fails: streams are a separate resource with their own ARN. The GetRecords calls Lambda makes on your behalf are not charged.
Cause 5: the filter is written in the wrong JSON
Event filtering lets the mapping drop records before they reach your function, which is efficient and silent: a filtered record produces no invocation, no log line and no error. A filter that matches nothing therefore looks exactly like a trigger that is not firing. Two things make DynamoDB filters wrong more often than right.
First, stream records are in DynamoDB's typed attribute format. Status is not "PAID"; it is {"S": "PAID"}. The filter has to match that shape, under the dynamodb key, and may only filter on the dynamodb key and the record metadata such as eventName:
# matches nothing: plain JSON
{ "Status": ["PAID"] }
# matches: typed, under dynamodb.NewImage
{ "eventName": ["INSERT", "MODIFY"], "dynamodb": { "NewImage": { "Status": { "S": ["PAID"] } } } }
# apply it
aws lambda update-event-source-mapping --uuid UUID --filter-criteria \
'{"Filters":[{"Pattern":"{\"eventName\":[\"INSERT\",\"MODIFY\"],\"dynamodb\":{\"NewImage\":{\"Status\":{\"S\":[\"PAID\"]}}}}"}]}'
Second, numbers are strings in stream records ({"N": "250"}), so numeric comparison operators do not work on DynamoDB filters; match the string value. And the exists operator only works on leaf nodes: "Organizations": [{"exists": true}] never matches a list attribute, while "Organizations": {"L": {"S": [{"exists": true}]}} does. A filter that is not valid JSON is rejected when you save it; a filter that is valid JSON and matches nothing is accepted and drops everything.
Also check that the filter needs an image you have. A filter on NewImage matches nothing on a KEYS_ONLY stream, and a filter on OldImage matches nothing on a NEW_IMAGE stream, because those fields are not in the record.
Cause 6: the write did not change anything, so there is no record
A PutItem or UpdateItem that leaves the item exactly as it was writes no stream record. A test that re-puts the same item, a job that "touches" rows to trigger the function, or an idempotent update that sets Status to the value it already has produces nothing on the stream, and nothing is correct. Change a value (a timestamp attribute is the usual trick) if you need a record. Our PutItem vs UpdateItem guide covers what each write changes.
Two related surprises. The stream only contains changes made after it was enabled; existing items are not replayed (use an export or a scan for a backfill). And deletes made by TTL do appear, as REMOVE records, which is a feature when you want it and a flood of invocations when you did not; filter on eventName if your function only cares about inserts.
"It stopped firing" that is really a stuck shard
Ethan's second incident, a month later, looked the same (no emails) and was the opposite problem. The mapping was firing constantly. One booking had a malformed email address, the function threw on it, and the mapping retried the same batch over and over. With the defaults, MaximumRetryAttempts and MaximumRecordAgeInSeconds are both -1, meaning retry until the records expire from the stream, so one bad record can block its shard for up to 24 hours. Records behind it in that shard wait; other shards carry on. From the outside, some bookings get emails and some never do. The signs:
| Signal | Not firing (causes 1-6) | Stuck shard |
|---|---|---|
Invocations | 0 | Steady or high |
Errors | 0 | Equal to invocations |
IteratorAge | No data | Climbing toward 86,400,000 ms |
LastProcessingResult | No records processed | PROBLEM: Function call failed |
| Function log | Silent | Same records, same error, every few seconds |
The fix has two halves. Make the function tolerate a bad record (catch, log, and report it as failed with partial batch responses so the good records in the batch are not retried), and set the mapping so a bad record cannot hold the shard:
aws lambda update-event-source-mapping --uuid UUID \
--maximum-retry-attempts 3 --maximum-record-age-in-seconds 3600 --bisect-batch-on-function-error \
--function-response-types ReportBatchItemFailures \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:ap-south-1:111122223333:bookings-stream-failures"}}'
--maximum-retry-attempts 3and--maximum-record-age-in-seconds 3600: give up on a batch after three tries or an hour, whichever first, instead of a day.--bisect-batch-on-function-error: split a failing batch in half and retry the halves, isolating the bad record without spending retries.ReportBatchItemFailures: your function returns{"batchItemFailures":[{"itemIdentifier":"SEQUENCE_NUMBER"}]}for the records it could not handle, and only those are retried.- The on-failure destination (standard SQS or SNS, or S3) receives a record with the shard, sequence numbers and stream ARN of the discarded batch, not the records themselves, so your function's log must capture what it could not process. The execution role needs
sqs:SendMessage(orsns:Publish, ors3:PutObjectpluss3:ListBucket) for it.
Delivery is at-least-once, so the function must also be safe to run twice on the same record; Ethan's email function now records the booking ID it has sent and skips repeats.
The remaining causes, briefly
| Symptom | Cause | Fix |
|---|---|---|
| Mapping refuses to create across Regions | Cross-Region triggers are not supported | Put the function in the table's Region |
| Table in another account | The stream needs a resource-based policy allowing your function | Add the policy on the stream, then create the mapping with the cross-account stream ARN |
| Intermittent throttling, records late | More than two readers on the same shard (two mappings plus a custom consumer, say) | Fan out from one function, or use Kinesis Data Streams for DynamoDB |
| Invocations throttled, IteratorAge climbing | Function reserved concurrency set to 0, or account concurrency exhausted | Raise or remove the reservation; check the Throttles metric |
| Function runs but sees no NewImage | Stream view type is KEYS_ONLY or OLD_IMAGE | New stream with NEW_AND_OLD_IMAGES, new mapping (cause 1) |
| Fires once per record, slowly | Batch size 1 or window 0 on a trickle of writes | Batch window of a few seconds; batch size 100 |
Several functions on one stream, how real-time it is, and the alternatives
Three questions come up as soon as the trigger works again, so here they are in one place.
Can two Lambda functions read the same stream? Yes, with two mappings, and that is the limit you should plan around: at most two readers per shard before throttling for a single-Region table, and one recommended for global tables. A third consumer (a second function plus a custom reader, say) shows up as intermittent throttling and late records on all of them. For more than two, have one function fan out (publish to SNS or EventBridge and let the others subscribe), or turn on Kinesis Data Streams for DynamoDB, which has no two-reader limit and longer retention.
How real-time is it? The mapping polls each shard about four times a second, and with the default batching window of 0 it invokes your function as soon as records are available, so a healthy trigger runs within a second or two of the write. Delays come from your own settings (a batching window of up to 300 seconds, a large batch size on a trickle of writes) or from a backlog the function cannot clear; IteratorAge is the number to watch.
Do I even want a Lambda here? If the goal is "send every change somewhere" rather than "run code on every change," EventBridge Pipes can read the stream and deliver to EventBridge, SQS, Step Functions or an API without a function, with filtering and enrichment built in. For an archive to S3, Kinesis Data Streams for DynamoDB into Firehose is the usual shape. A Lambda mapping remains the right tool when the work is custom code per record, as with Ethan's emails.
A mapping that works, end to end
# 1. stream on the table (skip if LatestStreamArn already exists with the view type you need)
aws dynamodb update-table --table-name bookings \
--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
STREAM=$(aws dynamodb describe-table --table-name bookings --query Table.LatestStreamArn --output text)
# 2. role can read streams
aws iam attach-role-policy --role-name send-confirmation-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaDynamoDBExecutionRole
# 3. the mapping: TRIM_HORIZON, sane retries, partial failures, a destination, a typed filter
aws lambda create-event-source-mapping --function-name send-confirmation --event-source-arn "$STREAM" \
--starting-position TRIM_HORIZON --batch-size 100 --maximum-batching-window-in-seconds 5 \
--maximum-retry-attempts 3 --maximum-record-age-in-seconds 3600 --bisect-batch-on-function-error \
--function-response-types ReportBatchItemFailures \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:ap-south-1:111122223333:bookings-stream-failures"}}' \
--filter-criteria '{"Filters":[{"Pattern":"{\"eventName\":[\"INSERT\",\"MODIFY\"],\"dynamodb\":{\"NewImage\":{\"Status\":{\"S\":[\"PAID\"]}}}}"}]}'
# 4. wait for Enabled, then a write that changes something
aws lambda list-event-source-mappings --function-name send-confirmation --query "EventSourceMappings[].[State,LastProcessingResult]"
aws dynamodb update-item --table-name bookings --key '{"BookingId":{"S":"B-1001"}}' \
--update-expression "SET #s = :p, UpdatedAt = :t" --expression-attribute-names '{"#s":"Status"}' \
--expression-attribute-values '{":p":{"S":"PAID"},":t":{"S":"2026-10-17T21:30:00Z"}}'
aws logs tail /aws/lambda/send-confirmation --since 5m
Read it against the causes: the ARN comes from the table at run time (cause 1); the mapping is created once the stream exists and you wait for Enabled before testing (cause 2); TRIM_HORIZON (cause 3); the managed policy (cause 4); a typed filter on fields the view type actually carries (cause 5); and the test write changes a value (cause 6). The retry and destination settings are the stuck-shard insurance.
Ethan's mapping now reads aws_dynamodb_table.bookings.stream_arn from Terraform, so the next view-type change replaces it automatically. Jake's addition to the runbook was one line: "If emails stop, compare the two ARNs before you read any logs."
For IT admins: keeping stream triggers honest
- Alarm on
IteratorAge(say, above 5 minutes) per function; it catches both a stuck shard and a function that is falling behind. - Alarm on the on-failure destination's depth; an empty queue with a healthy IteratorAge is the normal state.
- Never hard-code stream ARNs; reference the table's stream attribute in Terraform or CloudFormation so view-type changes replace the mapping.
- Set retries and record age on every mapping; the
-1defaults turn one bad record into a day of blocked records. - Scope the role to
table/NAME/stream/*, not to the table ARN, when you move off the managed policy. - Test filters with a real record from the stream, pasted into the Lambda console's filter tester, before you deploy them.
Frequently asked questions
Why is my DynamoDB Streams trigger not firing?
Compare the table's LatestStreamArn with the mapping's EventSourceArn (a re-enabled stream has a new ARN), check the mapping State is Enabled, confirm the execution role has the stream read permissions, check the filter uses typed JSON, and make sure your test write actually changed a value.
Why did changing the stream view type stop my Lambda trigger?
The view type cannot be edited, so the stream is disabled and a new one created with a new ARN. The event source mapping still points at the old stream. Create a new mapping on the new ARN and delete the old one.
What permissions does Lambda need to read a DynamoDB stream?
The function's execution role needs dynamodb:DescribeStream, GetRecords, GetShardIterator and ListStreams on the stream ARN. The managed policy AWSLambdaDynamoDBExecutionRole grants them plus CloudWatch Logs.
Why does my event filter match nothing?
Stream records use DynamoDB's typed format, so a filter must be written as {"dynamodb":{"NewImage":{"Status":{"S":["PAID"]}}}}. Plain JSON values match nothing, numeric operators are not supported, and exists only works on leaf nodes.
Why did my test write produce no stream record?
A PutItem or UpdateItem that leaves the item unchanged writes no record. Change a value, such as an updated-at timestamp. Items that existed before the stream was enabled are not replayed either.
What does LastProcessingResult "No records processed" mean?
The mapping polled the stream and found nothing to deliver. Either no changes were written, every record was filtered out, or the mapping is reading a stream that no longer receives writes, such as a replaced stream.
Should I use TRIM_HORIZON or LATEST?
TRIM_HORIZON. Mapping creation is eventually consistent and can take minutes to start polling, so LATEST can miss records written in that window. TRIM_HORIZON starts from the oldest record in the 24-hour stream and catches up.
Why does my trigger work for some items and not others?
One failing record blocks its shard while the mapping retries; other shards continue. With the default retries (-1) a bad record can block a shard for 24 hours. Set MaximumRetryAttempts and MaximumRecordAgeInSeconds, enable bisect and ReportBatchItemFailures, and add an on-failure destination.
What does IteratorAge tell me?
How old the last record in a delivered batch was. No data means records are not reaching the mapping. A climbing value means records arrive but the function is failing or too slow to keep up.
Why did Lambda disable my event source mapping?
StateTransitionReason shows Lambda made the change. It happens when the stream can no longer be read: the stream was deleted or replaced, or the role lost its permissions. Fix the cause, then re-enable or recreate the mapping.
Can a Lambda in another Region or account read my stream?
Cross-Region is not supported; the function must be in the table's Region. Cross-account works when the stream has a resource-based policy allowing the function, and the mapping is created with the full cross-account stream ARN.
How many Lambda functions can read one stream?
At most two readers per shard without throttling for a single-Region table, and one is recommended for global tables. For more consumers, fan out from one function or use Kinesis Data Streams for DynamoDB.
Do TTL deletes trigger the Lambda?
Yes, as REMOVE records with the item's keys and old image if the view type includes it. Filter on eventName INSERT and MODIFY if your function should ignore them.
Is the trigger exactly-once?
No. Event source mappings deliver at least once and duplicates can occur, so make your function idempotent, for example by recording processed keys or sequence numbers.
How long does it take for a new trigger to start?
Creating or updating a mapping is eventually consistent and may take several minutes before polling starts. Wait until State shows Enabled before testing.
Why does the on-failure destination not contain my records?
The destination receives metadata only: shard ID, sequence numbers, arrival times and stream ARN. Your function must log the records it fails on, and you must read them from the stream before the 24-hour retention ends.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • DevOps Engineer – Professional (DOP-C02).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. Every change to a DynamoDB table must trigger code. What is the standard design?
Enable DynamoDB Streams and attach a Lambda function through an event source mapping.
2. How long are records kept in a DynamoDB stream?
24 hours.
3. Which stream view type lets a function compare the item before and after a change?
NEW_AND_OLD_IMAGES.
4. One bad record keeps failing and blocks the shard. Which settings fix it?
MaximumRetryAttempts, MaximumRecordAgeInSeconds, BisectBatchOnFunctionError, ReportBatchItemFailures and an on-failure destination.
5. More than two consumers need the same table changes. What scales better?
Kinesis Data Streams for DynamoDB, or one function that fans out to SNS or EventBridge.
6. Changes must replicate to tables in other Regions automatically. Which feature uses streams for that?
DynamoDB global tables, which replicate changes across Regions.
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
The confirmation emails have not stopped since. The mapping's ARN comes from the table, the shard cannot be held hostage for more than an hour, and the failure queue has an alarm that has fired exactly once, for a booking whose email address was a phone number. Jake sent that customer a text instead.
📌 If you keep one line from this page
Compare the table's LatestStreamArn with the mapping's EventSourceArn, check State and LastProcessingResult, then IteratorAge: a replaced stream, a disabled mapping, a role without stream permissions, a plain-JSON filter, or a write that changed nothing covers nearly every "not firing"; a climbing IteratorAge with errors is a stuck shard, not a dead trigger.
TRIM_HORIZON on create · filters use {"S": [...]} · set retries and record age · at-least-once, so stay idempotent.
Revision note. Written October 17, 2026. "The watched pot never boils," the proverb says; the trigger watching the wrong pot never fires.