AWS Lambda JSON: Events, Responses, and API Gateway Integration
Last updated:
AWS Lambda handles JSON at every layer: the event object delivered by the runtime, the response shape expected by API Gateway, and the event structures from S3, SQS, and DynamoDB Streams. Each trigger has a distinct JSON schema, and several have subtle gotchas — particularly API Gateway's requirement that the response body be a string, not an object. This guide covers all of them with working TypeScript and Python examples.
Lambda Handler Structure and Event Parsing
The Lambda runtime deserializes the trigger payload into a JSON object before calling your handler. For API Gateway proxy integration, the HTTP request body arrives inside event.body as a raw string — you must parse it yourself.
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// API Gateway passes body as a JSON string
if (!event.body) {
return { statusCode: 400, body: JSON.stringify({ error: 'Missing body' }) }
}
let payload: { name: string; email: string }
try {
payload = JSON.parse(event.body)
} catch {
return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON' }) }
}
const result = { id: Date.now(), name: payload.name, email: payload.email }
return {
statusCode: 201,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
}
}Python equivalent:
import json
def handler(event, context):
# API Gateway proxy: body is a JSON string
body_str = event.get('body', '{}')
try:
body = json.loads(body_str)
except json.JSONDecodeError:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Invalid JSON body'}),
}
name = body.get('name', 'World')
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'message': f'Hello, {name}!'}),
}API Gateway Proxy Response Structure
API Gateway requires a specific JSON shape from your Lambda. The body field must always be a string — pass it through JSON.stringify() before returning.
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": "{\"message\": \"Success\", \"id\": 42}",
"isBase64Encoded": false
}| Wrong | Correct | Why |
|---|---|---|
body: { id: 1 } | body: JSON.stringify({ id: 1 }) | body must be a string |
statusCode: "200" | statusCode: 200 | must be integer |
| Omit headers | Include Content-Type | browser needs it |
| Return undefined | Return response object | Lambda requires a return value |
S3 Event Structure
S3 events deliver bucket and object metadata in event.Records. Decode the object key before using it — S3 URL-encodes special characters and replaces spaces with +.
import { S3Event } from 'aws-lambda'
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({})
export const handler = async (event: S3Event) => {
for (const record of event.Records) {
const bucket = record.s3.bucket.name
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '))
// Fetch the JSON file that was uploaded
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
const bodyStr = await response.Body?.transformToString('utf-8')
if (!bodyStr) continue
const data = JSON.parse(bodyStr) as Record<string, unknown>
console.log('Processed:', key, 'keys:', Object.keys(data).length)
}
}Key S3 event JSON fields:
{
"Records": [{
"eventSource": "aws:s3",
"eventName": "ObjectCreated:Put",
"s3": {
"bucket": { "name": "my-bucket", "arn": "arn:aws:s3:::my-bucket" },
"object": {
"key": "uploads/data.json",
"size": 1024,
"eTag": "abc123"
}
}
}]
}SQS Event Structure
SQS delivers messages in event.Records, where each record.body is a string — even when the original message was JSON. Parse it individually and handle errors carefully to avoid redriving entire batches.
import { SQSEvent } from 'aws-lambda'
interface OrderEvent {
orderId: string
customerId: string
total: number
items: Array<{ productId: string; qty: number }>
}
export const handler = async (event: SQSEvent) => {
for (const record of event.Records) {
let order: OrderEvent
try {
order = JSON.parse(record.body) as OrderEvent
} catch {
console.error('Invalid SQS message body:', record.body)
continue // skip — don't throw (avoids redrive loop for bad messages)
}
console.log(`Processing order ${order.orderId} for customer ${order.customerId}`)
await processOrder(order)
}
}
// Note: if your Lambda throws for a message, SQS will retry it up to maxReceiveCount
// Partial batch failure: return { batchItemFailures: [{ itemIdentifier: record.messageId }] }DynamoDB JSON and Streams
DynamoDB Streams deliver items in DynamoDB JSON — a typed format where each value is wrapped with its type descriptor. Use unmarshall() from @aws-sdk/util-dynamodb to convert to plain JavaScript objects.
import { DynamoDBStreamEvent } from 'aws-lambda'
import { unmarshall } from '@aws-sdk/util-dynamodb'
// DynamoDB stores items in DynamoDB JSON format (typed attribute values)
// { "id": { "S": "123" }, "age": { "N": "30" } }
// unmarshall() converts to plain JS: { id: "123", age: 30 }
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
if (record.eventName === 'INSERT' && record.dynamodb?.NewImage) {
const item = unmarshall(record.dynamodb.NewImage as Record<string, any>)
console.log('New item:', JSON.stringify(item, null, 2))
}
if (record.eventName === 'REMOVE' && record.dynamodb?.OldImage) {
const removed = unmarshall(record.dynamodb.OldImage as Record<string, any>)
console.log('Deleted:', removed.id)
}
}
}| DynamoDB Type | Symbol | Example | Unmarshalled |
|---|---|---|---|
| String | S | {"S": "hello"} | "hello" |
| Number | N | {"N": "42"} | 42 |
| Boolean | BOOL | {"BOOL": true} | true |
| Null | NULL | {"NULL": true} | null |
| Map | M | {"M": {"k": {"S": "v"}}} | {"k": "v"} |
| List | L | {"L": [{"N": "1"}]} | [1] |
Environment Variables and JSON Config
Lambda environment variables are always strings. Store JSON config as a serialized string and parse it once at module level so the parsed object is reused across warm invocations.
// Lambda env vars are strings — parse JSON config at cold start
const CONFIG = JSON.parse(process.env.APP_CONFIG ?? '{}') as {
dbHost: string
cacheTimeout: number
featureFlags: Record<string, boolean>
}
// Lazy-parsed (re-used across warm invocations)
export const handler = async (event: unknown) => {
if (CONFIG.featureFlags.betaFeature) {
// use beta feature
}
return { statusCode: 200, body: JSON.stringify({ host: CONFIG.dbHost }) }
}
// In Lambda console or SAM template:
// APP_CONFIG = {"dbHost":"db.internal","cacheTimeout":300,"featureFlags":{"betaFeature":true}}Common Lambda JSON Pitfalls and Solutions
| Pitfall | Symptom | Fix |
|---|---|---|
| body not stringified | API GW 502 Bad Gateway | JSON.stringify(result) before assigning to body |
| event.body not parsed | Body is a string, not object | JSON.parse(event.body) at top of handler |
| Large responses | 413 Payload Too Large | API GW max 10MB; compress or paginate |
| Missing CORS headers | Browser blocks response | Add Access-Control-Allow-Origin to headers |
| Circular JSON | TypeError in JSON.stringify | Use a custom replacer or avoid circular refs |
| SQS bad message loop | Infinite retry | Catch parse errors, return batchItemFailures |
// CORS-enabled response helper
function jsonResponse(statusCode: number, body: unknown, cors = true): APIGatewayProxyResult {
return {
statusCode,
headers: {
'Content-Type': 'application/json',
...(cors ? { 'Access-Control-Allow-Origin': '*' } : {}),
},
body: JSON.stringify(body),
}
}FAQ
Why does API Gateway send event.body as a string, not a JSON object?
API Gateway HTTP proxy integration passes the raw request body as a string to maintain flexibility — Lambda can receive any content type (JSON, XML, binary, form-encoded), so API Gateway cannot assume the body is always valid JSON. Your handler must call JSON.parse(event.body) manually. Always check event.headers["Content-Type"] first to confirm the body is JSON before parsing. If event.isBase64Encoded is true, base64-decode the body before parsing. A null or undefined event.body means the request had no body — guard against this before calling JSON.parse.
How do I return a JSON response from a Lambda function?
For API Gateway proxy integration, return { statusCode: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }. The body field MUST be a string — passing a plain object causes a 502 Bad Gateway error from API Gateway. If you are invoking Lambda directly (not via API Gateway), you can return any JSON-serializable value and the runtime will serialize it automatically. Always set the Content-Type header so browsers and API clients know how to interpret the response.
How do I handle JSON parsing errors in Lambda?
Wrap JSON.parse in a try/catch block and return an appropriate error response rather than letting the exception propagate. In Node.js, JSON.parse throws SyntaxError on invalid input; in Python, json.loads raises json.JSONDecodeError. For API Gateway handlers, return { statusCode: 400, body: JSON.stringify({ error: "Invalid JSON body" }) }. Always log the raw body string before parsing so you can inspect bad payloads in CloudWatch. For SQS triggers, avoid throwing on parse errors — return batchItemFailures instead to prevent the bad message from being retried as part of the entire batch.
How do I parse S3 event JSON in Lambda?
The S3 event delivers metadata in event.Records[0].s3.bucket.name and event.Records[0].s3.object.key. The object key is URL-encoded — always decode it with decodeURIComponent(key.replace(/\+/g, " ")) before using it in an SDK call. To read the actual file content, call GetObjectCommand and convert the response body stream to a string. Then JSON.parse the string. Never assume the uploaded file is valid JSON — wrap the parse in try/catch and log errors. Process each record in a loop since a single S3 event can contain multiple records.
What is DynamoDB JSON and how is it different from regular JSON?
DynamoDB JSON uses typed attribute descriptors to encode each value: {"S": "hello"} for strings, {"N": "42"} for numbers (always a string!), {"BOOL": true} for booleans, {"NULL": true} for null, {"L": [...]} for lists, and {"M": {...}} for maps. DynamoDB Streams events always deliver items in this format. Use unmarshall() from @aws-sdk/util-dynamodb to convert DynamoDB JSON to plain JavaScript objects, and marshall() to convert back.
How do I pass JSON configuration to a Lambda function?
Store the JSON config as a string in a Lambda environment variable (max 4KB total for all env vars). In the Lambda console or your SAM/CDK template, set APP_CONFIG to a JSON string. In your handler, call JSON.parse(process.env.APP_CONFIG ?? '')at module level — outside the handler function — so parsing happens once per cold start and the result is reused across all warm invocations. For larger configs (>4KB), use AWS SSM Parameter Store or Secrets Manager. For dynamic feature flags, consider AWS AppConfig, which supports safe deployments and rollbacks.
What is the maximum JSON response size from API Gateway Lambda?
API Gateway has a 10MB payload limit for both HTTP API and REST API Lambda proxy integrations. WebSocket APIs are limited to 128KB per message. If your JSON response exceeds 10MB, upload the data to S3 and return a pre-signed URL in the response instead. Alternatively, enable Lambda response streaming (supported with Lambda function URLs and HTTP APIs), which raises the limit to 20MB. For large but repetitive JSON, gzip compression typically achieves 70–90% size reduction — set Content-Encoding: gzip in the response headers and return base64-encoded compressed data with isBase64Encoded: true.
How do I enable CORS for JSON responses from Lambda via API Gateway?
Add Access-Control-Allow-Origin: * (or a specific origin) to every Lambda response's headers object. You must also handle the preflight OPTIONS request — either by adding an OPTIONS route that returns the CORS headers with a 200 status, or by enabling CORS in the API Gateway console/configuration, which handles OPTIONS automatically. For Lambda function URLs, configure CORS in the function URL settings. Missing or incorrect CORS headers silently cause browsers to block the response with a network error, which can be misleading to debug.
Definitions
- event object
- The JSON-parsed input passed to a Lambda handler; shape depends on the trigger (API Gateway, S3, SQS, DynamoDB Streams, EventBridge); always inspect the raw event JSON in CloudWatch logs when debugging.
- proxy integration
- API Gateway mode where the full HTTP request (headers, body, path, method) is forwarded to Lambda as a JSON event; the Lambda must return a specific JSON response shape with
statusCode,headers, andbodyas a string. - DynamoDB JSON
- A JSON encoding that annotates each value with its DynamoDB type descriptor (S, N, BOOL, L, M, NULL); the
@aws-sdk/util-dynamodbpackage providesmarshall/unmarshallhelpers to convert between DynamoDB JSON and plain JavaScript objects. - batchItemFailures
- Partial batch response format for SQS Lambda triggers; return a list of failed
messageIds so only those messages are retried rather than the entire batch, preventing good messages from being reprocessed. - cold start
- The latency penalty when Lambda initializes a new execution environment; module-level code (including
JSON.parseof env vars) runs once per cold start and is cached for subsequent warm invocations.
Further reading and primary sources
- AWS Lambda Developer Guide — Official reference for Lambda handler signatures, event formats, environment variables, and runtime behavior
- API Gateway Payload Formats — HTTP API Lambda proxy request and response payload format, including body stringification requirements
- AWS SDK v3 for JavaScript — AWS SDK v3 reference including S3Client, DynamoDB unmarshall, and SQS clients used in Lambda handlers