AWS Builder Center

Building a serverless API with Lambda, API Gateway, and DynamoDB

Building a serverless API with Lambda, API Gateway, and DynamoDB

Building a Serverless API with AWS Lambda, API Gateway, and DynamoDB

If you've ever spun up an EC2 instance just to serve a handful of API requests, you know the feeling: patching, scaling, paying for idle time. A serverless API removes most of that overhead. In this article, I'll walk through building a simple REST-style API using AWS Lambda, Amazon API Gateway (HTTP API), and Amazon DynamoDB, deployed with the AWS SAM (Serverless Application Model) CLI.
By the end, you'll have a working CRUD API you can extend for your own projects.

Why this stack?

  • AWS Lambda runs your code on demand. You pay only for execution time, and scaling is automatic.
  • API Gateway (HTTP API) gives you a managed HTTPS endpoint that routes requests to Lambda. HTTP APIs are cheaper and faster to set up than REST APIs, and are enough for most use cases.
  • DynamoDB is a fully managed NoSQL database with single-digit millisecond latency. In on-demand mode, there is no capacity to provision.
Together, they give you a backend with no servers to manage and a cost that starts near zero.

Architecture

The flow is simple:
  1. A client sends an HTTPS request to an API Gateway endpoint.
  2. API Gateway matches the route (for example POST /items) and invokes the Lambda function.
  3. Lambda reads from or writes to a DynamoDB table.
  4. Lambda returns a response, and API Gateway passes it back to the client.

Prerequisites

  • An AWS account with credentials configured (aws configure)
  • The AWS SAM CLI  installed
  • Python 3.12 installed locally

Step 1: Project structure

Create a folder with this layout:
1
2
3
4
items-api/
├── template.yaml
└── src/
└── app.py

Step 2: Define the infrastructure

SAM lets us describe the table, function, and API routes in a single template. Create template.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Simple serverless items API

Resources:
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH

ItemsFunction:
Type: AWS::Serverless::Function
Properties:
Handler: app.lambda_handler
Runtime: python3.12
CodeUri: src/
MemorySize: 256
Timeout: 10
Environment:
Variables:
TABLE_NAME: !Ref ItemsTable
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref ItemsTable
Events:
CreateItem:
Type: HttpApi
Properties:
Path: /items
Method: POST
ListItems:
Type: HttpApi
Properties:
Path: /items
Method: GET
GetItem:
Type: HttpApi
Properties:
Path: /items/{id}
Method: GET
DeleteItem:
Type: HttpApi
Properties:
Path: /items/{id}
Method: DELETE

Outputs:
ApiUrl:
Description: Base URL of the API
Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com'
A few things worth noting:
  • PAY_PER_REQUEST means on-demand capacity: no tuning required.
  • The DynamoDBCrudPolicy SAM policy template grants the function only the permissions it needs on only this table. That's least privilege with one line.
  • The table name is passed in as an environment variable, so nothing is hard-coded.

Step 3: Write the Lambda function

With HTTP APIs, Lambda receives a routeKey such as POST /items, which makes routing inside a single function clean. Create src/app.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json
import os
import uuid

import boto3

table = boto3.resource('dynamodb').Table(os.environ['TABLE_NAME'])

def respond(status, body=None):
response = {'statusCode': status, 'headers': {'Content-Type': 'application/json'}}
if body is not None:
response['body'] = json.dumps(body, default=str)
return response

def lambda_handler(event, context):
route = event['routeKey']

try:
if route == 'POST /items':
data = json.loads(event.get('body') or '{}')
if 'name' not in data:
return respond(400, {'message': 'name is required'})
item = {'id': str(uuid.uuid4()), 'name': data['name']}
table.put_item(Item=item)
return respond(201, item)

if route == 'GET /items':
result = table.scan(Limit=50)
return respond(200, result.get('Items', []))

item_id = event['pathParameters']['id']

if route == 'GET /items/{id}':
item = table.get_item(Key={'id': item_id}).get('Item')
if not item:
return respond(404, {'message': 'Item not found'})
return respond(200, item)

if route == 'DELETE /items/{id}':
table.delete_item(Key={'id': item_id})
return respond(204)

return respond(404, {'message': 'Route not found'})

except json.JSONDecodeError:
return respond(400, {'message': 'Invalid JSON body'})
except Exception as exc:
print(f'Unhandled error: {exc}')
return respond(500, {'message': 'Internal server error'})
The table resource is created outside the handler. Lambda reuses the execution environment between invocations, so the boto3 client is initialized once per cold start instead of on every request.

Step 4: Build and deploy

From the project root:
1
2
sam build
sam deploy --guided
Accept the defaults (and let SAM create a deployment bucket if prompted). When it finishes, the ApiUrl output shows your endpoint.

Step 5: Test it

Replace <ApiUrl> with your output value:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Create an item
curl -X POST <ApiUrl>/items \
-H 'Content-Type: application/json' \
-d '{"name": "My first item"}'

# List items
curl <ApiUrl>/items

# Get one item
curl <ApiUrl>/items/<id>

# Delete it
curl -X DELETE <ApiUrl>/items/<id>
You should see a 201 with the new item, then the item appear in the list, and finally a 204 on delete.

Best practices to apply next

This is a starting point. Before using it in production, consider:
  • Authentication: Add a JWT authorizer (for example with Amazon Cognito) on the HTTP API so only signed-in users can call it.
  • Input validation: Validate request bodies more strictly, or use a library such as Pydantic or AWS Lambda Powertools.
  • Avoid Scan at scale: Scan reads the whole table. For real workloads, design your keys and use Query with a partition key (and consider a Global Secondary Index for other access patterns).
  • Observability: Use AWS Lambda Powertools for structured logging, metrics, and tracing, and enable X-Ray where useful.
  • Throttling and CORS: Configure stage throttling and CORS settings on the API if browsers will call it directly.
  • Infrastructure as code: Keep the template in version control and deploy through a CI/CD pipeline.

What does it cost?

For a low-traffic project, very little. Lambda, API Gateway, and DynamoDB all bill per request, and each has a free tier. Check the current AWS pricing pages for exact numbers, since they can change, and set up an AWS Budget alert so a surprise never reaches your inbox.

Clean up

To avoid leftover resources, delete the stack when you're done experimenting:
1
sam delete

Conclusion

With about 100 lines of code and configuration, we built a fully managed, auto-scaling API with a persistent database and no servers to maintain. From here you can add authentication, more routes, or event-driven features such as DynamoDB Streams.
If you found this useful, I'd love to hear what you build with it. Share your questions or improvements in the comments*.*
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article