What it means
A Lambda handler does not return a value to a caller directly. It returns it to the runtime, which serialises it to JSON and hands the bytes back to the invoker. That serialisation step is invisible until it fails, and when it does the failure lands after every piece of work your function did.
This is what makes the error feel out of order. Your logs show the request arriving, the database being read, the result being assembled, and a final line saying the work is complete. Then the invocation fails. Nothing your code did went wrong — the value it produced simply could not be expressed in JSON, and the result is discarded along with everything it took to compute.
Almost every Python instance traces back to decimal.Decimal. Boto3's DynamoDB resource deserialises every numeric attribute as Decimal rather than int or float, deliberately, because floats cannot represent decimal fractions exactly and silently corrupting a monetary value would be worse than an inconvenient type. The consequence is that any item returned straight from DynamoDB is full of objects json.dumps refuses, often nested inside lists and maps where they are invisible in a quick read of the code.
The reason it survives local testing so reliably is that print() and logging call str(), which every one of these types implements perfectly. A Decimal prints as 4. A datetime prints as a readable timestamp. So a developer logs the object, sees exactly what they expected, returns it, and the runtime rejects it — the same value passing one representation and failing another. Being able to read the successful log lines and the marshal failure as one invocation is what makes that sequence obvious rather than baffling.
CloudWatch’s Errors metric: The error escaped your handler, so Lambda reports the invocation as failed and CloudWatch’s Errors metric counts it. LogStitch classified the invocation above as uncaught.
What it looks like in CloudWatch
This is the shape the failure arrives in: the lines of one invocation scattered among everything else the log group received at the same moment.
CloudWatch Logs Before
6 raw lines, in the order CloudWatch delivered them.
LogStitch After
The same lines, grouped into the invocation they belong to.
Error8a2f6c04-7b19-4e35-90d8-1c4b7e5a3f92
- 14:07:33.114PLAT
START RequestId: 8a2f6c04-7b19-4e35-90d8-1c4b7e5a3f92 Version: $LATEST
- 14:07:33.118INFO
INFO Fetching order ord_92ba7c
- 14:07:33.244INFO
INFO Order loaded items=3 total=4288
- 14:07:33.288ERROR
[ERROR] Runtime.MarshalError: Unable to marshal response: Object of type Decimal is not JSON serializable
- 14:07:33.402PLAT
END RequestId: 8a2f6c04-7b19-4e35-90d8-1c4b7e5a3f92
- 14:07:33.402PLAT
REPORT RequestId: 8a2f6c04-7b19-4e35-90d8-1c4b7e5a3f92 Duration: 288.44 ms Billed Duration: 289 ms Memory Size: 512 MB Max Memory Used: 96 MB Status: error
The panel on the right is generated by running the excerpt on the left through the same parser that powers the free web stitcher — it is what the tool actually produces for this input, not an illustration of it.
How to confirm it from the logs
Position is the giveaway. This appears after all of your own logging, including whatever you logged just before returning, because it happens when the runtime marshals the value your handler already produced. If your logs show the work completing and then a failure, this is the shape.
The type named in the message tells you what to fix, and Decimal is by far the most common because that is what boto3 returns for every DynamoDB number. datetime is second. Both are ordinary Python objects with no JSON equivalent, and neither is anything you wrote.
Causes, most likely first
The value came from DynamoDB and contains Decimal
Check whether the returned object originated from a get_item, query or scan. Boto3 deserialises every DynamoDB number as decimal.Decimal to avoid float precision loss, so any item returned straight from the table carries them — including nested inside lists and maps where they are easy to miss.
A datetime, date, or UUID is in the response
Look for timestamp or identifier fields in the returned structure. datetime, date, UUID and bytes all serialise fine in your own logs via str() and all fail json.dumps, so printing the object successfully proves nothing about whether it can be returned.
A set is being returned where a list is needed
Search for set() or a set comprehension in the response path. Sets have no JSON representation at all, and DynamoDB string sets deserialise into Python sets, so this often arrives from the same place the Decimals do.
A whole SDK response object is being returned
Check whether the handler returns the raw result of a boto3 call rather than a field from it. Those responses carry ResponseMetadata, streaming bodies and datetime values, none of which belong in an API response even when they can be serialised.
Fixes
Give json.dumps a default for the types it does not know
Serialise deliberately in the handler rather than letting the runtime do it implicitly. A default function handles the types Python has and JSON does not, and — importantly — it converts Decimal to int when the value is whole, so identifiers and counts do not come back as 4.0.
pythonimport json
from decimal import Decimal
from datetime import date, datetime
from uuid import UUID
def encode(value):
if isinstance(value, Decimal):
# Whole numbers should not become 4.0 in the response.
return int(value) if value % 1 == 0 else float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (set, frozenset)):
return sorted(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return value.decode()
raise TypeError(f"Cannot serialise {type(value).__name__}")
def lambda_handler(event, context):
item = table.get_item(Key={"id": event["id"]})["Item"]
return {
"statusCode": 200,
"body": json.dumps(item, default=encode),
}
Convert at the boundary rather than at the end
Normalising the item as soon as it leaves DynamoDB means the rest of the function works with ordinary Python types, and nothing downstream has to remember that numbers are secretly Decimals.
pythonfrom decimal import Decimal
def normalise(value):
if isinstance(value, list):
return [normalise(item) for item in value]
if isinstance(value, dict):
return {key: normalise(item) for key, item in value.items()}
if isinstance(value, Decimal):
return int(value) if value % 1 == 0 else float(value)
return value
item = normalise(table.get_item(Key=key)["Item"])
Return a shape you built, not one a client handed you
Constructing the response explicitly — naming the fields you intend to return — avoids the whole class of problem and stops SDK metadata leaking into your API. It is also the only version that keeps working when the upstream response shape changes.
Also seen as
The same underlying failure, worded differently by a different runtime, SDK version, or logging layer. All of these land here — there is no separate page for each phrasing.
Related errors
Errors that show up alongside this one, or that people mistake for it.
References
- AWS Lambda Developer Guide — Define Lambda function handler in Python
- Boto3 documentation — DynamoDB conditions and types
LogStitch finds this automatically, across every invocation in your account.
Paste a log excerpt into the free web stitcher and see it grouped, classified, and measured in your browser — nothing is uploaded. Or run the Mac app against your own AWS profiles and get the same view over every function you own.