Building a Serverless REST API: Lambda + DynamoDB + API Gateway
This is the most common shape of small backend on AWS: API Gateway takes the HTTP request, Lambda runs the logic, DynamoDB holds the record.
Busque em todas as páginas da documentação
This is the most common shape of small backend on AWS: API Gateway takes the HTTP request, Lambda runs the logic, DynamoDB holds the record.
Each piece is covered in depth elsewhere in this cookbook - this page focuses on the wiring between them: how an HTTP request becomes a table operation, how errors map back to status codes, and what changes when you go from a single call to a real, deployable API.
Quick-reference recipe card - copy-paste ready.
# --- Python (boto3) ---
import boto3, json
table = boto3.resource("dynamodb").Table("Tasks")
def handler(event, context):
method = event["requestContext"]["http"]["method"]
if method == "POST":
body = json.loads(event["body"])
table.put_item(Item={"taskId": body["taskId"], "title": body["title"], "done": False})
return {"statusCode": 201, "body": json.dumps(body)}
if method == "GET":
task_id = event["pathParameters"]["taskId"]
item = table.get_item(Key={"taskId": task_id}).get("Item")
if item is None:
return {"statusCode": 404, "body": json.dumps({"error": "not found"})}
return {"statusCode": 200, "body": json.dumps(item)}
return {"statusCode": 405, "body": json.dumps({"error": "method not allowed"})}// --- TypeScript (AWS SDK v3) ---
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler = async (event: any) => {
const method = event.requestContext.http.method;
if (method === "POST") {
const body = JSON.parse(event.body);
await doc.send(new PutCommand({ TableName: "Tasks", Item: { taskId: body.taskId, title: body.title, done: false } }));
return { statusCode: 201, body: JSON.stringify(body) };
}
if (method === "GET") {
const taskId = event.pathParameters.taskId;
const { Item } = await doc.send(new GetCommand({ TableName: "Tasks", Key: { taskId } }));
if (!Item) return { statusCode: 404, body: JSON.stringify({ error: "not found" }) };
return { statusCode: 200, body: JSON.stringify(Item) };
}
return { statusCode: 405, body: JSON.stringify({ error: "method not allowed" }) };
};When to reach for this:
A single HTTP API with two routes, POST /tasks and GET /tasks/{taskId}, both proxied to one Lambda function backed by one DynamoDB table.
# --- Python (boto3) ---
import boto3
apigw = boto3.client("apigatewayv2")
lam = boto3.client("lambda")
ddb = boto3.client("dynamodb")
# 1. Table: a single partition key is enough for a get-by-id pattern.
ddb.create_table(
TableName="Tasks",
KeySchema=[{"AttributeName": "taskId", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "taskId", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
ddb.get_waiter("table_exists").wait(TableName="Tasks")
# 2. HTTP API: the cheaper, lower-latency API Gateway type for a simple proxy.
api = apigw.create_api(Name="tasks-api", ProtocolType="HTTP")
api_id = api["ApiId"]
# 3. Lambda integration: API Gateway invokes the function directly.
integration = apigw.create_integration(
ApiId=api_id,
IntegrationType="AWS_PROXY",
IntegrationUri="arn:aws:lambda:us-east-1:123456789012:function:tasks-handler",
PayloadFormatVersion="2.0",
)
# 4. Routes: map HTTP verbs and paths to the same integration.
apigw.create_route(ApiId=api_id, RouteKey="POST /tasks", Target=f"integrations/{integration['IntegrationId']}")
apigw.create_route(ApiId=api_id, RouteKey="GET /tasks/{taskId}", Target=f"integrations/{integration['IntegrationId']}")
# 5. Permission: allow API Gateway to invoke the function.
lam.add_permission(
FunctionName="tasks-handler",
StatementId="apigw-invoke",
Action="lambda:InvokeFunction",
Principal="apigateway.amazonaws.com",
SourceArn=f"arn:aws:execute-api:us-east-1:123456789012:{api_id}/*/*",
)// --- TypeScript (AWS SDK v3) ---
import { DynamoDBClient, CreateTableCommand, waitUntilTableExists } from "@aws-sdk/client-dynamodb";
import {
ApiGatewayV2Client, CreateApiCommand, CreateIntegrationCommand, CreateRouteCommand,
} from "@aws-sdk/client-apigatewayv2";
import { LambdaClient, AddPermissionCommand } from "@aws-sdk/client-lambda";
const ddb = new DynamoDBClient({});
const apigw = new ApiGatewayV2Client({});
const lam = new LambdaClient({});
// 1. Table: a single partition key is enough for a get-by-id pattern.
await ddb.send(new CreateTableCommand({
TableName: "Tasks",
KeySchema: [{ AttributeName: "taskId", KeyType: "HASH" }],
AttributeDefinitions: [{ AttributeName: "taskId", AttributeType: "S" }],
BillingMode: "PAY_PER_REQUEST",
}));
await waitUntilTableExists({ client: ddb, maxWaitTime: 120 }, { TableName: "Tasks" });
// 2. HTTP API: the cheaper, lower-latency API Gateway type for a simple proxy.
const api = await apigw.send(new CreateApiCommand({ Name: "tasks-api", ProtocolType: "HTTP" }));
// 3. Lambda integration: API Gateway invokes the function directly.
const integration = await apigw.send(new CreateIntegrationCommand({
ApiId: api.ApiId,
IntegrationType: "AWS_PROXY",
IntegrationUri: "arn:aws:lambda:us-east-1:123456789012:function:tasks-handler",
PayloadFormatVersion: "2.0",
}));
// 4. Routes: map HTTP verbs and paths to the same integration.
await apigw.send(new CreateRouteCommand({ ApiId: api.ApiId, RouteKey: "POST /tasks", Target: `integrations/${integration.IntegrationId}` }));
await apigw.send(new CreateRouteCommand({ ApiId: api.ApiId, RouteKey: "GET /tasks/{taskId}", Target: `integrations/${integration.IntegrationId}` }));
// 5. Permission: allow API Gateway to invoke the function.
await lam.send(new AddPermissionCommand({
FunctionName: "tasks-handler",
StatementId: "apigw-invoke",
Action: "lambda:InvokeFunction",
Principal: "apigateway.amazonaws.com",
SourceArn: `arn:aws:execute-api:us-east-1:123456789012:${api.ApiId}/*/*`,
}));What this demonstrates:
add_permission / AddPermissionCommand is the resource-based policy step people forget - without it, API Gateway cannot invoke the function.AWS_PROXY integrations, forwards the entire request (headers, path, query, body) as one JSON event to Lambda.statusCode, headers, body) or API Gateway returns a 502 - this is the most common first bug in a new build.taskId), which supports GetItem by id but not listing all tasks; a real build would add a secondary access pattern per the DynamoDB section's single-table design guidance.The handler's job is translating HTTP verbs into the right DynamoDB call and the right status code - not just calling DynamoDB and hoping.
# --- Python (boto3) ---
# A conditional write turns POST into a real "create" - fails if the id already exists.
table.put_item(
Item={"taskId": task_id, "title": title, "done": False},
ConditionExpression="attribute_not_exists(taskId)",
)// --- TypeScript (AWS SDK v3) ---
// A conditional write turns POST into a real "create" - fails if the id already exists.
await doc.send(new PutCommand({
TableName: "Tasks",
Item: { taskId, title, done: false },
ConditionExpression: "attribute_not_exists(taskId)",
}));Catch the conditional-check failure and return 409 Conflict instead of a generic 500 - this is the difference between an API that behaves and one that just forwards a stack trace.
Scope the function's execution role to exactly the table and actions it calls - dynamodb:PutItem and dynamodb:GetItem on the Tasks table ARN, nothing broader.
{statusCode, body}; anything else surfaces as a 502 from API Gateway. Fix: always return the full shape, even on error paths.AccessDeniedException calling the function. Fix: call add_permission/AddPermissionCommand once per API, or let IaC manage it.None/undefined result from GetItem is a normal "not found," not an error. Fix: check for the item explicitly and return 404.PutItem silently overwrites an existing id. Fix: add ConditionExpression="attribute_not_exists(...)" for true creates and handle the conflict.| Alternative | Use When | Don't Use When |
|---|---|---|
| API Gateway + Lambda + DynamoDB (this page) | Spiky or low-volume CRUD traffic, no server management wanted | Sustained high throughput where a always-on container is cheaper |
| ECS/Fargate + RDS | Long-running processes, relational queries, existing SQL data model | You want zero idle cost and near-instant scale to zero |
| Lambda Function URLs + DynamoDB | A single function, no need for API Gateway's routing/auth features | Multiple routes or methods that benefit from a shared API resource |
| AppSync (GraphQL) + DynamoDB | Clients need flexible queries over a graph of related data | A small number of fixed REST endpoints is all you need |
HTTP API (v2) is lighter, cheaper, and lower-latency for a simple Lambda proxy like this one. Choose REST API (v1) when you need its extra features, such as built-in request validation, usage plans, or private VPC endpoints.
Almost always because the handler's return value does not match the expected proxy response shape (statusCode, body, optionally headers). Check that every code path, including error handling, returns that shape.
A single partition key table only supports lookups by that key. Add a secondary access pattern - a Scan for small tables, or a Global Secondary Index for a real list-by-status query - as covered in the DynamoDB section's access-pattern guidance.
Not always. Lambda function URLs give you a single HTTPS endpoint per function with less setup, at the cost of API Gateway's routing, custom domains, and request validation features. Use function URLs for a single-route service.
Keep the handler's dependencies minimal, initialize the DynamoDB client at module scope so it is reused across warm invocations, and consider provisioned concurrency only if cold-start latency is measured to matter for your traffic.
Without it, a second POST with the same taskId silently overwrites the first item. The ConditionExpression="attribute_not_exists(taskId)" makes PutItem fail instead, which the handler turns into a 409 Conflict.
Not by default. Lambda functions calling DynamoDB and API Gateway need no VPC - both are reached over AWS's public API endpoints via IAM auth. Add a VPC only if the function must reach a private resource like an RDS instance.
API Gateway HTTP APIs support JWT authorizers (for example backed by Cognito) or a Lambda authorizer attached per route. Add the authorizer to the route definition rather than checking tokens by hand inside the handler.
At minimum: the table and routes exist and match the IaC definition, the invoke permission is present, and a smoke test hits both the create and get-by-id paths, including the not-found and duplicate-create cases.
The wiring is the same DynamoDB calls; the difference is that a request-per-invocation model means no persistent connections or in-memory caching between requests, so each invocation re-establishes clients (mitigated by module-scope reuse across warm starts).
Stack versions: This page was written for boto3 1.43.x (Python 3.10+) and the AWS SDK for JavaScript v3 (Node.js 18+).
Revisado por Chris St. John·Última atualização: 23 de jul. de 2026