Lambda "Unable to Marshal Response": The JSON Fix
AWS Lambda Unable to marshal response means your function returned something its runtime could not convert into the required response format. In Python, datetime, Decimal, sets, and custom objects are common offenders; in JavaScript, look for BigInt or circular structures. The counterintuitive catch is that your order update or database write may already have completed successfully, even though the caller receives a function error. Convert the returned value into a deliberate JSON-safe shape, test that exact shape, and make retries safe before invoking the operation again.
Jake runs a neighborhood phone-repair shop with a small booking website. His example Lambda function reads an order in DynamoDB, confirms a $249.99 repair, and prepares the booking response. The database operation completes, and his own log prints “order confirmed,” but the caller receives a failure. This is a fictional teaching scenario, not a claim about a real customer or a production test.
The risk is bigger than a broken confirmation page. If Jake’s customer retries because the screen shows an error, the same appointment could be created twice unless the application prevents duplicate transactions. Our goal is to fix the returned value, preserve the amount accurately, and separate the completed business operation from its failed response.
Jake: "If the appointment went into my database, why did the customer see an error?"
Ethan: "Your work can finish before Lambda packages the answer. The response failed at the exit door, not necessarily at the repair counter."
What the marshal error means at the return boundary
You may be staring at a handler that reaches its final return without raising any exception. The Python managed runtime still needs to transform that return value into something it can hand to the invoker. Marshalling is that packaging step: turning in-memory objects into an exchangeable response, normally JSON for a buffered JSON response. The runtime can report a failure if the object cannot be represented.
JSON supports objects, arrays, strings, numbers, booleans, and null. It does not automatically understand a Python datetime instance, a decimal.Decimal instance, a set, or one of your application’s domain classes. A Python dictionary is supported as an outer container, but a dictionary containing one unsupported nested value still fails. A clean-looking print() output is therefore not evidence of JSON compatibility.
The handler boundary also explains the misleading timeline. First your code can read or write data, call external services, and write success messages. Then it produces a return value. The managed runtime prepares that value for delivery, and a marshal failure can occur there. Your client sees failure even though earlier side effects may remain committed.
The practical fix is a conversion boundary in your own code. Decide exactly which fields leave the function, choose their public JSON types, and construct that new object before returning it. This is preferable to exposing an entire SDK object or a Python class and hoping the runtime can encode it. The response should be an intentionally designed message, not an accidental snapshot of program memory.
| Stage | What you can observe | What it proves |
|---|---|---|
| Database operation | Application log says item stored | The code reached the logged point; not that delivery succeeded |
| Handler returns | No raised exception in your code | Your Python control flow completed |
| Runtime serializes | Runtime.MarshalError appears | Returned value could not be packaged |
| Caller receives | FunctionError or an HTTP error | End-to-end request did not complete normally |
Jake: "So a green success message in my log isn’t a receipt?"
Ethan: "Right. It is a note that one part of your process completed. Check the actual invocation result before calling the transaction successful end to end."
Find the exact error in CloudWatch and console tests
When the Lambda console reports a failed invocation, expand the execution result rather than relying only on the colored status indicator. A Python marshal failure typically identifies Runtime.MarshalError as its error type. The message can include the type name, such as datetime or Decimal, which is a useful starting point. These error strings are examples of runtime output, not a promise that all supported runtimes format them identically.
CloudWatch Logs is the next place to look. Each invocation has a request ID that you can use to correlate the application lines with the runtime’s error line and report. Depending on the runtime and logging settings, the error may be presented as a plain-text line or a structured record. Search for the phrase Unable to marshal response and inspect messages around that invocation.
If your function is behind API Gateway, the browser often does not receive the Python runtime message. It may receive a generic HTTP error because the integration was unable to obtain or use the function response. Open the Lambda log group in the same Region, locate the invocation, and determine whether Lambda itself raised a marshal error or merely returned a malformed proxy envelope. Those failures require different fixes.
A missing log entry does not establish a successful execution. Verify the function name, Region, account, log group, relevant time range, and whether the caller uses a published version or alias. If you invoke one version in the console but API Gateway targets another, you can easily compare unrelated behavior. Match the request or trace identifier wherever the integration exposes one.
{
"errorMessage": "Unable to marshal response: Object of type Decimal is not JSON serializable",
"errorType": "Runtime.MarshalError",
"requestId": "example-request-id",
"stackTrace": []
}
- Open Lambda → Functions and choose your function in the correct account and Region.
- Use Test with a controlled event and expand the complete execution result.
- Open Monitor and follow the CloudWatch log group link.
- Locate
Runtime.MarshalErrorand record the unsupported type plus request ID. - Examine the exact return expression and every field it contains, not only the top-level dictionary.
Which language and value caused your response failure?
You can narrow a serialization failure much faster by identifying both the language runtime and the exact kind of value returned. Python and Node.js both have JSON-like response formats, but their native types and serializers behave differently. Java uses its own object-to-JSON mechanisms. A fix that is correct for one runtime should not be copied blindly into another.
In Python, the common causes are dates, decimals, sets, nested binary values, and arbitrary class instances. These may print normally and travel inside dictionaries without causing a problem until serialization. In Node.js, BigInt and circular references are especially relevant; undefined behaves differently and can silently disappear from objects. In Java, unsupported POJOs or unsuitable serializer configuration create another class of failure.
Watch for nested structures returned by AWS SDKs. An outer object that appears to contain only strings and arrays can hide a decimal or set several levels below. A user-created object may also contain a field holding an SDK client or a file handle rather than ordinary response data. Your first repair should reduce the response to just the fields that the calling application truly requires.
The quick conversions in this table are useful starting points, not universal replacements. Treat money, timestamps, identifiers, and binary data as separate design decisions. For example, converting money to floating point can change precision, and decoding arbitrary bytes as UTF-8 can fail or damage data. Choose the meaning of the field first, then its serialization format.
| Source value | Symptom | Safer response representation |
|---|---|---|
Python datetime | Not JSON serializable | Timezone-aware ISO 8601 string |
Python Decimal | Not JSON serializable | Decimal string; integer minor units when suitable |
Python set | Unsupported collection | Array, with explicit ordering if useful |
Python nested bytes | Unsupported nested binary | Base64 string plus format metadata |
| Python custom class | Unknown object type | Explicit dictionary/DTO |
Node.js BigInt | JSON.stringify throws | String preserving every digit |
| Node.js circular object | Converting circular structure to JSON | New acyclic object |
| Node.js undefined properties | Fields disappear or become null in arrays | Explicit nullable or optional schema |
| Java custom POJO | Serializer cannot handle class | Supported DTO or custom serializer |
Fix Python with json.dumps, isoformat, str, and a default function
If the Python error points to a non-JSON type, create a reusable conversion function rather than scattering emergency str() calls throughout the handler. Python’s json.dumps() accepts a default callback for objects it cannot encode on its own. The callback can turn a date into ISO text, a Decimal into decimal text, or a set into an array. Unsupported values should still raise an error that you can investigate.
You also want to prevent invalid JSON numbers. Python’s json.dumps() normally permits special float values such as NaN when configured with its default behavior, even though they are not standard JSON numbers. Passing allow_nan=False detects those cases instead of producing output that another parser may reject. This is useful when your response includes calculated measurements or values from analytical libraries.
The converter below deliberately represents Decimal as a string. That suits an API contract where exact decimal digits matter, such as an order price. If your client expects a JSON number, make that a separate, documented field-level decision. The code also encodes nested bytes with base64 text; a caller must know that those fields contain encoded binary rather than normal text.
Notice that this helper uses json.loads(json.dumps(...)) to obtain a new structure containing ordinary JSON-compatible Python types. You can return that structure directly for a JSON-oriented Lambda invocation, or create a JSON string from it for an API Gateway body. The local conversion is not necessary for every simple handler, but it makes the serialization boundary explicit and testable.
import base64
import json
from datetime import date, datetime
from decimal import Decimal
def json_default(value):
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return str(value) # Preserve decimal digits
if isinstance(value, set):
return sorted(value, key=str)
if isinstance(value, bytes):
return base64.b64encode(value).decode("ascii")
raise TypeError(f"Unsupported type: {type(value).__name__}")
def json_safe(value):
return json.loads(json.dumps(
value, default=json_default, allow_nan=False
))
def lambda_handler(event, context):
return json_safe({
"createdAt": datetime.fromisoformat("2026-10-14T10:30:00+00:00"),
"price": Decimal("249.99"),
"labels": {"repair", "priority"}
})
For one timestamp, value.isoformat() is clearer than a generic serializer. For one Decimal field, str(value) is clearer than a helper that changes every object. For a numeric counter known to be integral, int(value) may be appropriate. The safest code expresses the type of each public field deliberately rather than merely making an exception disappear.
Jake: "Could I just use default=str everywhere?"
Ethan: "It is handy for finding the problem, but it can turn a complex object into meaningless text. Tell your customer what fields they actually need and serialize those deliberately."
Choose Decimal strings, floats, or integer minor units correctly
If your error mentions Decimal, it is tempting to write float(value) and move on. That does remove a standard Python JSON serialization obstacle, but binary floating-point cannot represent every decimal fraction exactly. A money field that looks like 249.99 on screen might not have that exact value internally. For financial records, the output format should preserve the business meaning rather than prioritize convenience.
A string such as "249.99" preserves the digits from the Decimal representation. The client must treat it as an amount, not an arbitrary label, and parse it carefully if arithmetic is needed. Another approach is integer minor units, such as 24999 cents for $249.99 in a currency with two decimal minor units. That requires a defined currency and rounding policy, but integer values travel cleanly through JSON.
A float is reasonable for many approximate readings, percentages, and visualization data where binary approximation is understood. It is less suitable as the authoritative representation of a billed price or a quantity requiring exact decimal arithmetic. If your existing API already promises numeric JSON amounts, changing them to strings could break clients. Treat a serialization fix as an API contract change when it alters field types.
Here is a worked example rather than a theoretical warning. Two repair parts at $124.995 each produce a decimal subtotal of $249.990 before your chosen currency-rounding rule. If the charge must be expressed in cents, you need an explicit rounding policy before returning an integer. A blind float conversion does not establish that policy for you; it simply changes the representation.
from decimal import Decimal, ROUND_HALF_UP
unit_price = Decimal("124.995")
subtotal = unit_price * 2 # Decimal('249.990')
rounded = subtotal.quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
) # Decimal('249.99')
amount_text = str(rounded) # '249.99'
amount_cents = int(rounded * 100) # 24999
# Select ONE documented representation for your API.
| Representation | Example | Use when |
|---|---|---|
| Decimal string | "249.99" | Exact displayed decimal value matters |
| JSON number from float | 249.99 | Approximation is acceptable or contract requires numeric |
| Integer cents | 24999 | Currency policy and minor-unit conversion are explicit |
Why DynamoDB results are frequent Decimal offenders
If your function reads DynamoDB through the Boto3 Table resource, inspect number attributes first. Boto3’s DynamoDB resource interface commonly exposes DynamoDB number values as Python Decimal instances. That is useful for decimal handling inside Python, but the default JSON encoder does not serialize Decimal. A perfectly successful get_item() can therefore be followed by a runtime response error.
The problem may be hidden deep in a nested DynamoDB item. A price could be a Decimal, quantities in a list could be Decimals, and a Number Set might surface as a Python set of Decimals. Converting the first visible price field does not guarantee the rest of the response is safe. Inspect each returned field, or map the item into a purpose-built public schema.
The Boto3 low-level DynamoDB client has a different, typed attribute representation: values are wrapped with type markers such as S or N. It may not create the same Decimal-containing ordinary application dictionary. Returning raw low-level API results is still not a good public API design. The important point is to know which interface you use and convert at the boundary.
Database metadata creates another unnecessary complication. A get_item() result may contain response metadata that a browser does not need, alongside the actual Item. Returning the entire SDK response increases response size and couples your public contract to SDK details. Build a small object with order ID, amount, quantity, timestamp, and status instead; leave internal metadata in your observability system where it belongs.
import boto3
table = boto3.resource("dynamodb").Table("Orders")
def lambda_handler(event, context):
result = table.get_item(Key={"orderId": "ORDER-1001"})
item = result.get("Item")
# Returning item directly is risky when it contains Decimal or set.
return item if item else {"found": False}
Worked DynamoDB example: return a clean API Gateway order
Suppose your booking page requests a single order and expects an HTTP response. You want a result that the Lambda runtime can serialize and that API Gateway can deliver through a conventional buffered Lambda proxy integration. The example below uses DynamoDB get_item(), validates the requested key, and returns a stable JSON body. The table name comes from an environment variable rather than being embedded in every copy of the code.
This example assumes orderId is the complete primary key for the table. If your table has a partition key and a sort key, include both fields in the Key argument. The function execution role needs permission for the DynamoDB read operation. The code also expects a stored createdAt string, rather than attempting to serialize an arbitrary datetime directly.
Pay attention to the missing-order branch. A missing item is not a serialization failure, and returning a deliberate 404 is more useful than accidentally raising KeyError. A missing path parameter receives a 400. These HTTP codes are inside a successfully serialized proxy envelope, so API Gateway can distinguish an ordinary application result from a Lambda runtime failure.
The conversion step makes amount a string and quantity an integer. It does not pass the complete DynamoDB object to the caller, and it does not require the Lambda runtime to guess how Decimal should appear. The resulting envelope uses a string body. Both the body and the outer envelope can be inspected independently when diagnosing a failure.
import json
import os
import boto3
TABLE_NAME = os.environ["TABLE_NAME"]
table = boto3.resource("dynamodb").Table(TABLE_NAME)
def reply(status, payload):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"isBase64Encoded": False,
"body": json.dumps(payload, allow_nan=False)
}
def lambda_handler(event, context):
path = event.get("pathParameters") or {}
order_id = path.get("orderId")
if not order_id:
return reply(400, {"message": "Missing orderId"})
result = table.get_item(Key={"orderId": order_id})
item = result.get("Item")
if item is None:
return reply(404, {"message": "Order not found"})
order = {
"orderId": str(item["orderId"]),
"amount": str(item["amount"]),
"quantity": int(item["quantity"]),
"createdAt": str(item["createdAt"])
}
return reply(200, {"order": order})
{
"order": {
"orderId": "ORDER-1001",
"amount": "249.99",
"quantity": 2,
"createdAt": "2026-10-14T10:30:00+00:00"
}
}
If the table stores optional fields, use clear defaults or conditional additions rather than indexing a missing attribute. For example, item.get("notes") may appropriately become JSON null, while a required missing amount should trigger a controlled application error. Decide which fields are required in the response schema. That distinction makes your function robust to older items, migrations, and partial writes.
Jake: "Why bother copying four fields when the database already gave me an item?"
Ethan: "Because the customer needs a receipt, not the entire filing cabinet. That small conversion also keeps your money and timestamps in predictable formats."
Convert nested DynamoDB objects without losing the field meanings
You might have an order with line items, discounts, measurements, and a list of tracking events rather than only four scalar attributes. At that point, manual field-by-field mapping can become tedious. A recursive converter is useful for internal JSON-compatible processing, provided it applies a clear rule to every type. The important choice is whether numeric decimals become strings or intentionally selected integers and floats.
A recursive sanitizer should recognize ordinary dictionaries, lists, tuples, sets, Decimal values, and date objects. It should leave supported scalar values unchanged, and it should raise on an unexpected class instead of silently producing a dubious representation. For sets, choose a consistent ordering when clients expect stable output. For mixed incomparable values, sorting by string can be deterministic enough for presentation but is not a semantic sort.
Once you have a sanitized object, you can pass it to json.dumps() again at the API boundary. That final call catches accidental regressions, such as a developer adding a fresh unsupported object later. It also gives you a place to enforce allow_nan=False. Recursive conversion is not automatically better than an explicit schema: public APIs still benefit from a known set of fields.
If a field uses a binary value from DynamoDB, think about how the client will interpret it. Base64 text is an encoding, not automatically a meaningful document. The response should also specify the file’s type or provide an S3 download URL for larger blobs. Returning a massive binary field merely because it happened to be stored inside the item is usually a poor fit for an order-summary endpoint.
from datetime import date, datetime
from decimal import Decimal
def clean(value):
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, dict):
return {str(k): clean(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [clean(v) for v in value]
if isinstance(value, set):
return [clean(v) for v in sorted(value, key=str)]
if value is None or isinstance(value, (str, int, float, bool)):
return value
raise TypeError(f"Unexpected type: {type(value).__name__}")
Make both the API Gateway body and outer response valid
If you call Lambda through API Gateway, first identify the API kind and payload format version. A REST API Lambda proxy integration expects an envelope containing an HTTP status code and a string body. HTTP API payload format 1.0 has a similar explicit structure, while HTTP API payload format 2.0 can infer a response from a simple JSON return value. These are separate contracts, and a serializer fix does not automatically satisfy every integration.
In a conventional proxy envelope, your application may construct the inner response as a dictionary. Convert that inner dictionary into a JSON string with json.dumps() and assign it to body. Then return the outer dictionary containing statusCode, headers, and the string body. The Lambda runtime still has to serialize that outer object, so headers must also contain appropriate values rather than custom objects.
Two failure categories can look the same from a browser. If Lambda cannot serialize a Python Decimal in the outer response, that is a runtime marshal failure. If Lambda successfully returns an ordinary JSON-serializable dictionary but the API Gateway REST proxy envelope has the wrong shape, API Gateway can return 502 Bad Gateway. Read the Lambda execution result before changing API Gateway mappings or guessing at proxy behavior.
For HTTP API format 2.0, a simple valid JSON response without statusCode can be treated as the body with an inferred 200 and JSON content type. However, you still need explicit fields when you want non-default status codes or headers. Streaming API Gateway integrations have yet another output framing model, so apply these buffered proxy examples to buffered integrations rather than all Lambda response modes.
import json
def lambda_handler(event, context):
order = {"orderId": "ORDER-1001", "amount": "249.99"}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"isBase64Encoded": False,
"body": json.dumps(order)
}
| Lambda result | Lambda serialization | API Gateway proxy result |
|---|---|---|
{"amount": Decimal("249.99")} | Fails in Python | No valid integration response |
{"statusCode":200,"body": {"ok":True}} | Can serialize | Wrong body type for REST proxy |
{"statusCode":200,"body":"{\"ok\":true}"} | Serializes | Conventional REST proxy envelope |
Plain {"ok":true} on HTTP API 2.0 | Serializes | May use inferred response shape |
Repair Node.js BigInt, cycles, and undefined-heavy objects
If your handler runs on Node.js, begin with a local JSON.stringify() of the exact object you intend to return. A BigInt is not a regular JSON number, and the default JSON serializer throws when it encounters one. You can convert a large integer to a string, preserving all its digits. This is especially helpful for identifiers that should not be rounded or used in arithmetic by the browser.
Circular structures create a different problem. An object can contain a reference to itself, or a nested object can point back to an ancestor. JavaScript can work with that structure in memory, but ordinary JSON has no reference mechanism to represent the loop. Rather than adding a generic circular-reference workaround, return a small new object containing the useful public fields.
An object full of undefined fields may produce a more subtle bug. JSON.stringify typically drops undefined-valued object properties, while undefined entries in arrays become null. A response might therefore be syntactically valid but missing crucial fields. Establish whether a field is optional, explicitly null, or required, and normalize the value accordingly before returning it.
Also remember that SDK response objects may contain additional metadata or internal types that your client does not need. A new object constructed from known primitives is often easier to test and safer to expose. When using API Gateway, combine that clean object with the correct payload-version response envelope instead of returning internal request or response objects unchanged.
export const handler = async () => {
const transactionId = 9007199254740993n;
const internal = { id: "ORDER-1001", amount: "249.99" };
internal.self = internal; // Circular: do not return this object
const safe = {
transactionId: transactionId.toString(),
orderId: internal.id,
amount: internal.amount,
notes: null
};
JSON.stringify(safe); // Local serialization guard
return safe;
};
Jake: "Why does the large ID need to become text?"
Ethan: "JavaScript’s normal Number type can lose digits on very large integers. A string keeps the identifier exact and JSON-safe."
Use Java DTOs or a configured serializer for unsupported objects
If your Lambda uses Java, the managed runtime needs to serialize the object produced by the handler. A class that behaves well inside your application may not have an appropriate serializer representation for Lambda’s response. Fields containing framework internals, streams, lazy-loaded data, or unsupported object graphs create unnecessary complications. The simplest fix is often to return a small data transfer object or a map of supported values.
Start with the required output contract. A public order result might need an order identifier, amount, and status; it does not need an entity manager, connection pool, or the entire database entity. Make those fields accessible through the standard serialization behavior used by your chosen runtime, and test the resulting JSON. You should not assume that every class without a particular annotation automatically fails; the exact serializer rules matter.
When built-in serialization is inadequate for an established data model, Java managed Lambda runtimes can use a CustomPojoSerializer implementation. That involves registering an implementation through the service-provider configuration in your deployment artifact. It is a real capability, but it introduces more moving parts than a plain response DTO. Choose it because you need a custom contract, not merely because the original class is complicated.
An alternative is a RequestStreamHandler, which gives you input and output streams to handle deliberately. That is useful when you must control bytes or serialization end to end. It also means your handler owns more of the encoding work. For a straightforward JSON API, a supported DTO is easier for the next developer to maintain.
import java.util.Map;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
public class OrderHandler implements
RequestHandler<Map<String,Object>, Map<String,Object>> {
@Override
public Map<String,Object> handleRequest(
Map<String,Object> event, Context context) {
return Map.of(
"orderId", "ORDER-1001",
"amount", "249.99",
"status", "confirmed"
);
}
}
Return PDF, image, or other binary data using the correct path
If you need to return a PDF, PNG, ZIP, or other binary document, determine whether the caller uses direct Lambda invocation, API Gateway, or a streaming endpoint. Those paths each use their own response format. In Python’s managed runtime, a top-level bytes return is a documented special case for direct buffered responses and is passed through as raw binary with an application/unknown content type. Nested bytes inside a dictionary still are not ordinary JSON.
For a conventional buffered API Gateway Lambda proxy integration, base64-encode the binary bytes and place the encoded text in the string body. Set isBase64Encoded to true and provide a suitable content type. REST APIs also need their binary media types configured correctly. In browser cases, the first Accept header can affect how API Gateway treats binary media, so the REST API configuration deserves attention.
Base64 expands the transmitted text representation, and the API path has payload limits. As of October 2026, REST API buffered payloads have a 10 MB limit, while Lambda buffered synchronous invocation has a 6 MB response limit. Consider the smaller effective constraint when designing the end-to-end flow. For larger files, storing the object in S3 and returning a presigned URL is often a cleaner approach.
Streaming is a separate architecture rather than a trick involving the same dictionary. Lambda response streaming, where it is available to you, supports substantially larger responses through the supported invocation methods. API Gateway REST response-streaming integrations use a different response format from buffered proxy replies. Select the appropriate mode before copying binary code into your handler.
import base64
def lambda_handler(event, context):
with open("/tmp/report.pdf", "rb") as f:
pdf_bytes = f.read()
return {
"statusCode": 200,
"headers": {"Content-Type": "application/pdf"},
"isBase64Encoded": True,
"body": base64.b64encode(pdf_bytes).decode("ascii")
}
Test serialization locally before you deploy
If you only discover bad return types during a live console test, bring the serialization check closer to your source code. Python’s json.dumps() is a practical first guard for normal JSON responses. It catches unsupported values before Lambda’s final return boundary, and you can add it to unit tests that exercise real response-building functions. A small regression test for the troublesome DynamoDB item is worth more than another generic logging statement.
Unit-test the actual object you return, not a simplified copy. Include nested decimals, an absent optional field, timezone-aware timestamps, and any binary metadata that your API contract permits. For a proxy integration, test that the envelope serializes and that body is a string that can be parsed as JSON. This catches two independent classes of failure before you deploy.
A successful local JSON encode is a good sign, not a full replacement for running under the managed runtime. Different language versions, dependency packaging, Java serializers, and integration formats can still change the result. AWS SAM CLI provides a way to invoke a local function with an event file, and the Lambda console provides another checkpoint against the deployed function. Use the same representative event and compare the response structure.
A useful test suite checks both supported and unsupported cases. Verify the exact type and representation of each important field rather than simply asserting that no exception occurred. For money, assert the expected decimal string. For dates, assert the ISO timestamp, including the timezone offset. For errors, assert that the function returns the declared HTTP code and correctly formatted response body.
import json
from decimal import Decimal
def build_order():
return {"orderId": "ORDER-1001", "amount": str(Decimal("249.99"))}
def test_clean_order_is_json_safe():
data = build_order()
encoded = json.dumps(data, allow_nan=False)
assert json.loads(encoded)["amount"] == "249.99"
def test_proxy_envelope_has_string_body():
response = {"statusCode": 200, "body": json.dumps(build_order())}
json.dumps(response, allow_nan=False)
assert isinstance(response["body"], str)
assert json.loads(response["body"])["orderId"] == "ORDER-1001"
def test_raw_decimal_fails_standard_json():
try:
json.dumps({"amount": Decimal("249.99")})
except TypeError:
pass
else:
raise AssertionError("A raw Decimal unexpectedly encoded")
python -m pytest test_response.py
sam local invoke -e event.json
Use the console and CLI to compare the deployed response
If your local tests pass but the real invocation fails, first verify that you are investigating the correct deployed function. Open the Lambda console in the correct AWS account and Region, inspect the function runtime and handler, and confirm whether your caller uses a version or alias. An old published version can keep returning the problematic value even after you changed the working source code. This is a common reason a valid local fix appears ineffective.
A direct synchronous AWS CLI invoke gives you two separate pieces of evidence: invocation metadata and a payload file. Use --invocation-type RequestResponse and save the result to a local file. Inspect FunctionError in the CLI metadata and the payload content. An HTTP-level status code of 200 from the Invoke API does not prove the Lambda function returned successfully.
To inspect logs, filter the relevant CloudWatch log group for the error text, then match the request identifier. Check the time window and permissions if the filter produces no results. A log search that runs against the wrong Region or function name is not a useful confirmation. If the error only occurs through API Gateway, also inspect the integration configuration and the response shape expected by that API type.
Use controlled test events because production handlers may have side effects even when they later return a marshal error. A read-only test path is preferable for initial diagnostics. If you must test a write, use a dedicated idempotency key and a known test record. Compare the outcome in the downstream service before trying again, especially after the caller reported an error.
- Choose the actual target function, Region, account, version, and alias.
- Invoke with a representative, side-effect-safe event and capture both CLI metadata and payload.
- If
FunctionErrorappears, inspect the payload for the error type and request ID. - Find the matching invocation in CloudWatch Logs and identify the return field that failed.
- Deploy the corrected response mapper, then repeat the same test and a real integration request.
aws lambda get-function-configuration --function-name my-function
aws lambda invoke --function-name my-function --invocation-type RequestResponse --cli-binary-format raw-in-base64-out --payload '{}' response.json
aws logs filter-log-events --log-group-name /aws/lambda/my-function --filter-pattern '"Unable to marshal response"'
Why completed business work and a failed invocation can coexist
If your log says “payment recorded” or “order saved,” remember that your application emitted that message before the runtime finished producing a response. A CloudWatch application log is evidence of what your code reached, not proof that the caller received a usable result. Lambda can detect a response-serialization failure after the business operation is already committed. Your investigation should preserve both facts instead of treating them as contradictory.
For direct synchronous invocation, the Lambda Invoke API can return HTTP 200 while a header and the result payload identify a function error. The service request itself completed; the function result was an error. This distinction matters for SDK callers that look only at the transport status. Read FunctionError or X-Amz-Function-Error and the response body to determine the application outcome.
For API Gateway, the browser might see a generic server error. That is not proof that the original database call failed or that API Gateway retried the business operation. The gateway communicates failure to the client when the integration cannot supply a valid result. Your client’s own retry behavior, load balancer policies, and application logic determine what might happen next.
A good design separates committing a transaction from communicating its result and supports recovery if the communication fails. Use stable order IDs and an idempotency mechanism for operations that cannot safely happen twice. On a retry, the handler should be able to return the already-established outcome rather than create a duplicate. That is an application guarantee, not something marshal-error handling provides automatically.
Jake: "The order is saved, but the checkout page says failure. Should the customer press Pay again?"
Ethan: "First check whether that order already completed. The return error cannot undo the earlier work, so make repeated requests return the same result instead of charging twice."
Understand retries, Errors metrics, and duplicate processing
If Lambda’s runtime reports a marshal failure, it is a function execution error. You can observe it through the function’s error reporting and CloudWatch Lambda Errors metric. That makes an Errors alarm useful for noticing the issue, but the metric does not explain which returned field caused it. Use logs and error messages for that detail, and check the invocation source to understand whether the event will be seen again.
For synchronous invocations, Lambda itself does not automatically retry ordinary function-code errors on your behalf. The client or upstream service might have its own retry behavior, so you still need to plan for duplicate requests. For asynchronous invocations, Lambda retries function errors up to two more times by default. A successful write followed by marshal failure can therefore run the same event repeatedly unless you protect the operation.
Asynchronous retry settings are available on the function, version, or alias through Configuration → Asynchronous invocation. The retry-attempt setting can be configured from zero to two. You can inspect or update it using CLI operations such as get-function-event-invoke-config and update-function-event-invoke-config. Adjusting retries may reduce repeated side effects, but it does not replace correct serialization or idempotency.
Event source mappings have their own behavior. For an SQS-triggered function, a failed batch can become visible again based on queue visibility, mapping configuration, and failure handling. Streams and other sources have separate rules. The safe general rule is to assume that an event can be delivered more than once and that the application must recognize work already performed. Use request identifiers or transaction keys chosen for that purpose.
| Invocation | What failure looks like | Who handles replay |
|---|---|---|
| Direct synchronous Invoke | FunctionError, potentially HTTP 200 transport | Calling client decides whether to retry |
| API Gateway request | Caller commonly receives HTTP 5xx | Application/client retry policy |
| Asynchronous Lambda Event | Function error, standard two additional retries | Lambda async retry configuration |
| SQS event source mapping | Failed batch or item processing | Queue and mapping settings |
aws lambda get-function-event-invoke-config --function-name my-function
aws lambda update-function-event-invoke-config --function-name my-function --maximum-retry-attempts 0
Changing asynchronous retry attempts to zero can be appropriate for a narrowly designed workload, but it also changes how genuine transient failures are handled. Decide that tradeoff deliberately and configure a failure destination or dead-letter mechanism where needed. The safer primary fix is still a serializable response and repeat-safe business logic. A smaller retry count is an operational choice, not a serializer.
Estimate the retry impact without inventing a bill
If you want to understand why this apparently small response bug matters, measure how many times the original side effect may run. Imagine one order confirmation performs a write and then fails while marshalling the result. With the standard asynchronous function-error policy, the first attempt can be followed by two more attempts. That means one event can execute the write path three times unless the handler recognizes the existing order.
Consider 100 distinct order events that all reach the same failure. At most 300 total execution attempts may result from those 100 events under that basic default error-retry path, before considering other delivery behavior. The actual number depends on configuration and what happened during processing. This is a workload-count example, not a claim that every event will inevitably execute three times.
The money cost depends on your actual Lambda memory settings, execution duration, request pricing, free-tier or credit eligibility, downstream DynamoDB operations, and any other services called. For this incident, a more useful first calculation is the number of duplicated business operations. Three attempts for a booking are not simply three harmless log records if each attempt creates another reservation or sends another notification.
If you need a dollar estimate, use the current AWS pricing pages for the actual Region and the account’s pricing terms. Start with the measured duration and memory of the failing function, multiply by the relevant billed attempts, and include downstream calls only when you know their type and billing basis. Keeping the example at the transaction level avoids presenting a made-up universal per-invocation price. The exact order-count arithmetic already demonstrates why the serialization boundary deserves an alarm.
| Scenario | Initial events | Potential attempts at three per event |
|---|---|---|
| One affected order | 1 | 3 |
| Small batch of affected orders | 10 | 30 |
| Larger affected batch | 100 | 300 |
Handle missing values, dates, custom classes, and silent JSON changes
If a response appears JSON-compatible, inspect its less obvious edges. A custom class might define an attractive __str__() representation without defining a JSON representation. A set may contain otherwise harmless strings but is still unsupported as a JSON collection. A datetime may be converted to text without a timezone, leaving callers unable to interpret the timestamp reliably. Your serializer needs both technical validity and meaningful data design.
Pay attention to dictionaries with non-string keys. JSON object field names are strings, and Python’s JSON encoder does not represent every possible key type the same way. Mapping a non-string key into text can create collisions when different original keys stringify alike. Public API schemas should normally use known string field names. If your data is really a collection of key-value records, an array of objects may be clearer.
The value None is already JSON-compatible and becomes null. It does not need a custom serializer, but you still need to decide whether null is meaningful for a required field. Python’s float("nan") can generate nonstandard JSON unless you enforce strict encoding, and infinite values require attention too. Make your test suite reject those representations instead of finding out when a client parser refuses them.
Some errors are about exceptions thrown during response construction rather than runtime marshalling. If json.dumps() raises a TypeError inside your handler, you will usually see an ordinary exception trace from your own code, not necessarily Runtime.MarshalError. That is still helpful because the failure happens earlier and can carry a more useful location. Preserve that distinction when you read the logs so you investigate the right function boundary.
When the fix works locally but production still fails
If your local serializer passes yet the live function still fails, compare the exact deployed artifact and actual return path. One handler branch may use your sanitizer while an error branch returns a raw SDK object. A scheduled invocation may receive a different event shape from your local test. A published alias might still point to older code. Your troubleshooting should establish precisely which branch and version produced the failing response.
Use a safe diagnostic that logs type information rather than customer records. Log keys or type names when they are not sensitive, but keep tokens, complete database items, personal identifiers, and private payloads out of CloudWatch. You can also serialize a deliberately sanitized copy inside the handler and catch the exception to obtain a clearer path through your own conversion code. Keep those diagnostics behind appropriate controls in production.
If the source is Java, confirm that the intended CustomPojoSerializer service registration is in the deployed package. If the source is Node.js, confirm no late code path adds a BigInt or cyclic object after your local serialization guard. If the source is API Gateway, inspect the response payload format and distinguish a Lambda runtime failure from the gateway rejecting the envelope. This sequence avoids changing unrelated IAM roles or memory limits without evidence.
For an AWS Support case, gather function ARN or name, Region, runtime and runtime version where available, relevant request IDs, sanitized error messages, approximate occurrence times, invocation type, and steps that reproduce the failure. Describe whether business side effects completed and whether retries occurred. Support can help investigate the service behavior, but it cannot choose the semantics of your custom price or object class. That remains part of your application contract.
Frequently asked questions about Lambda response marshalling
What does Unable to marshal response mean in AWS Lambda?
It means the Lambda runtime could not convert the handler return value into the required response representation. In Python, Decimal, datetime, sets, and custom objects are frequent causes. Convert returned values into an intentional JSON-compatible structure.
What does Runtime.MarshalError mean?
Runtime.MarshalError is a Python Lambda runtime response-serialization error. It can occur after your handler completed its business logic and returned an unsupported value. The associated message often names the offending type.
Why did my Lambda succeed in logs but fail for the caller?
An application log records a point your code reached; it does not confirm that the runtime delivered the final response. A database write might already have succeeded when response marshalling fails. Inspect the Lambda function error and make retries safe.
How do I fix Decimal is not JSON serializable in Lambda?
Convert Decimal values explicitly before returning them. Use str(value) to preserve decimal digits, or define an integer minor-unit or float representation when the API contract requires it. Inspect nested DynamoDB values and sets.
How do I return a datetime object from Python Lambda?
Return an ISO 8601 string produced by datetime.isoformat() instead of the datetime instance. Include timezone information for timestamps representing an instant. A date string is ordinary JSON text.
Can json.dumps default=str fix Unable to marshal response?
It can often remove an immediate Python encoding error by converting unsupported objects to text. However, it may produce unhelpful or unstable strings for arbitrary classes. Use explicit type conversions in a public API.
Why does a DynamoDB get_item response cause a marshal error?
The Boto3 Table resource commonly returns DynamoDB numbers as Python Decimal objects and can return Python sets. Python’s standard JSON encoder does not handle those types automatically. Map the retrieved item into a JSON-safe response before returning it.
Should the API Gateway Lambda response body be a string?
For conventional REST proxy and HTTP API 1.0 explicit envelopes, body should be a string, often created with json.dumps(). HTTP API payload format 2.0 can infer a response from a simple JSON result. Match the exact integration format.
Does Runtime.MarshalError count toward Lambda Errors alarms?
A runtime response-serialization failure is a function error and can contribute to the Lambda Errors metric. An alarm can detect increased errors but not identify the bad field by itself. Use CloudWatch Logs for the detailed cause.
Will Lambda retry an Unable to marshal response error?
Direct synchronous invocations are not automatically retried by Lambda for ordinary function errors. Asynchronous invocations retry function errors up to two additional times by default. Queue and stream event source mappings follow their own retry behavior.
How do I return binary data from Lambda?
For buffered API Gateway Lambda proxy responses, base64-encode bytes into the string body and set isBase64Encoded to true. REST APIs also need appropriate binary media type configuration. A top-level bytes return in a direct Python Lambda invocation is a documented special case.
Why does Node.js Lambda fail when returning BigInt?
Standard JSON.stringify does not directly support BigInt. Convert the value to a decimal string before returning it, especially for large identifiers. That preserves digits without forcing the value into an imprecise Number.
Can circular references break Node.js Lambda responses?
Yes. A cyclic JavaScript object cannot be represented directly by ordinary JSON.stringify. Return a new acyclic response containing just the fields the client needs.
How do I fix Java Lambda response serialization?
Use a supported response DTO, record, or map whose fields can be serialized by the configured runtime serializer. For special cases, Java managed Lambda runtimes support CustomPojoSerializer or stream-based handlers. Match the solution to your deployment and API contract.
How do I test Lambda JSON serialization before deploying?
In Python, serialize the exact response with json.dumps and use allow_nan=False for strict JSON numbers. Test the API Gateway envelope and parse its string body separately. Then invoke the deployed function with a representative controlled event.
Why does Lambda Invoke return HTTP 200 when the function failed?
For synchronous Lambda Invoke requests, HTTP 200 can mean the service processed the invocation request even if the function returned an error. Inspect the FunctionError field or X-Amz-Function-Error header and payload. API Gateway may expose the failure as an HTTP 5xx instead.
For Jake’s shop, the fix is not merely to make a red error disappear. It is to return a clear confirmation with an exact amount, understand which operations may already have completed, and let a repeated request recover without duplicating a booking. If you see a new type in the marshal error, add a deliberate field conversion and a regression test. You can then treat the failed response as a diagnosable boundary problem rather than a mystery about whether the whole function ran.
📌 If you keep one line from this page
The work can succeed while the response fails: return only deliberate JSON-safe values and make a second invocation safe.
Revision note. Written October 15, 2026, with the 6 MB synchronous limit and the two default asynchronous retries in place. Finish what you start: convert the value before you return it, and the work your function already did stays done.