Building a REST API with Python FastAPI on AWS Lambda: A Complete Guide
If you’ve ever wondered how to build a fast, modern REST API without managing servers, this guide is for you. We’ll build a complete REST API using Python FastAPI and deploy it to AWS Lambda with API Gateway — a fully serverless architecture that scales automatically and costs almost nothing at low traffic.
This isn’t a theoretical tutorial. I built a production expense tracker app using this exact stack, and I’ll share what I learned along the way — including the gotchas that most tutorials skip.
By the end of this guide, you’ll have a working CRUD API deployed to the cloud — and a reusable project template for your next backend project.
Table of Contents
- What We’re Building
- Why FastAPI + AWS Lambda?
- Flask vs FastAPI — Why I Switched
- Prerequisites
- Project Structure
- Step 1: Define Your Data Models
- Step 2: Build the DynamoDB Service Layer
- Step 3: Create the API Routes
- Step 4: Create the FastAPI App with Mangum
- Step 5: Define the SAM Template
- Step 6: Set Up Dependencies
- Step 7: Deploy
- Step 8: Test Your API
- Understanding the Request Flow
- Adding Authentication with Cognito
- Lessons from Production
- Common Mistakes to Avoid
- Cost Breakdown
- Wrapping Up
What We’re Building
We’re building a fully functional Items API — a RESTful backend that supports creating, reading, updating, and deleting items. Think of it as the backend for an inventory system, product catalog, or any CRUD-based application.
Here’s what the final API will support:
- POST /api/v1/items/ — Create a new item
- GET /api/v1/items/ — List all items
- GET /api/v1/items/{item_id} — Get a specific item
- PUT /api/v1/items/{item_id} — Update an item
- DELETE /api/v1/items/{item_id} — Delete an item
- GET /docs — Auto-generated interactive API documentation (Swagger UI)
The entire thing runs serverless on AWS — no servers to manage, no scaling to configure, and it costs essentially nothing until you start getting serious traffic.
Why FastAPI + AWS Lambda?
Let me explain why each piece of this stack was chosen:
FastAPI — The Modern Python Web Framework
FastAPI has quickly become the go-to choice for building APIs in Python, and for good reason:
- Type-safe by design: You define your request/response models using Python type hints. FastAPI validates every incoming request automatically — no more manual validation code.
- Auto-generated docs: Visit
/docson any FastAPI app and you get a fully interactive Swagger UI. Your frontend developers will thank you. - Async-ready: Built on top of Starlette and Pydantic, FastAPI supports async/await natively. This matters for I/O-heavy operations like database calls.
- Performance: FastAPI is one of the fastest Python frameworks available, rivaling Node.js and Go for I/O-bound workloads. Benchmarks consistently show it outperforming Flask and Django REST Framework.
AWS Lambda — True Serverless
AWS Lambda eliminates the operational overhead of running a server:
- Zero server management: No EC2 instances to patch, no load balancers to configure, no auto-scaling groups to tune.
- Pay-per-request: You’re billed only for the compute time your function uses. At low traffic, this is essentially free. No traffic = $0.
- Auto-scaling: Lambda handles anywhere from 1 request to thousands of concurrent requests without any configuration.
- High availability: Lambda runs across multiple Availability Zones by default.
The Glue: API Gateway + DynamoDB
- API Gateway sits in front of Lambda, providing HTTPS endpoints, request routing, and throttling.
- DynamoDB gives us a fully managed NoSQL database that scales automatically and costs nothing at low usage (25 GB free forever).
Together, this stack gives you a production-grade API at a fraction of the cost and effort of traditional hosting.
Flask vs FastAPI — Why I Switched
If you’re coming from Flask, you might wonder why you should switch. Here’s a real comparison:
With Flask, you write this:
# Flask — manual validation, no type safety
@app.route('/items', methods=['POST'])
def create_item():
data = request.get_json()
# Manual validation — tedious and error-prone
if not data:
return jsonify({"error": "No data provided"}), 400
if 'name' not in data:
return jsonify({"error": "Name is required"}), 400
if 'price' not in data or not isinstance(data['price'], (int, float)):
return jsonify({"error": "Valid price is required"}), 400
if data['price'] <= 0:
return jsonify({"error": "Price must be positive"}), 400
# Finally, process the data...
item = save_to_db(data)
return jsonify(item), 201
With FastAPI, the same thing becomes:
# FastAPI — automatic validation, type safety, auto-docs
@app.post('/items', response_model=ItemResponse, status_code=201)
def create_item(item: ItemCreate):
# Validation is AUTOMATIC based on the Pydantic model
# If 'name' is missing or 'price' is negative, FastAPI returns
# a detailed 422 error with exact field information — for free
result = save_to_db(item.model_dump())
return result
That’s not just less code — it’s safer code. The Pydantic model defines exactly what the API accepts. Any request that doesn’t match gets rejected with a detailed error message before your code even runs.
Prerequisites
Before we start, make sure you have these tools installed on your machine:
- Python 3.9+ — Check with
python3 --version - AWS CLI configured with your credentials — Run
aws configureand enter your access key, secret key, and default region (e.g.,us-east-1) - AWS SAM CLI — This is the tool for building and deploying serverless applications. Install it from the official guide
- Basic familiarity with Python — You should know classes, functions, decorators, and type hints
- A text editor — VS Code with the Python extension is my recommendation
If you don’t have an AWS account yet, create one at aws.amazon.com. The free tier covers everything we’ll use in this tutorial.
Project Structure
Before writing any code, let’s plan our project structure. A well-organized project makes your code easier to test, maintain, and scale:
fastapi-lambda-api/
├── src/
│ ├── __init__.py # Makes src a Python package
│ ├── main.py # FastAPI app + Lambda handler
│ ├── routes/
│ │ ├── __init__.py
│ │ └── items.py # Item CRUD endpoints
│ ├── models/
│ │ ├── __init__.py
│ │ └── item.py # Pydantic request/response models
│ └── services/
│ ├── __init__.py
│ └── dynamodb.py # DynamoDB database operations
├── tests/
│ ├── __init__.py
│ └── test_items.py # API tests
├── template.yaml # AWS SAM infrastructure template
├── requirements.txt # Python dependencies
└── README.md
Why this structure? It follows the separation of concerns principle:
routes/— Handles HTTP request/response logic. Routes should be thin — they validate input, call a service, and return a response.models/— Defines the shape of your data using Pydantic. These models serve as your API contract and documentation.services/— Contains business logic and database operations. By isolating database code here, you can swap DynamoDB for PostgreSQL later without touching your routes.main.py— The entry point that wires everything together and exposes the Lambda handler.
This structure scales well. As your API grows, you add more route files, more models, and more services — without making any single file too complex.
Step 1: Define Your Data Models
Let’s start with Pydantic models. This is arguably the most important step because these models define your API contract — what data comes in, what goes out, and what gets rejected.
# src/models/item.py
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
import uuid
class ItemCreate(BaseModel):
"""
Schema for creating a new item.
This model validates incoming POST requests.
All fields are required except 'description'.
"""
name: str = Field(
..., # ... means "required"
min_length=1,
max_length=100,
description="Item name"
)
description: Optional[str] = Field(
None, # None means "optional, defaults to null"
max_length=500,
description="Item description"
)
price: float = Field(
...,
gt=0, # gt = "greater than" — rejects 0 and negatives
description="Item price, must be greater than 0"
)
category: str = Field(
...,
min_length=1,
max_length=50,
description="Item category"
)
class Config:
json_schema_extra = {
"example": {
"name": "Python Crash Course",
"description": "A hands-on introduction to Python",
"price": 29.99,
"category": "Books"
}
}
class ItemUpdate(BaseModel):
"""
Schema for updating an existing item.
ALL fields are optional here. Only the fields you include
in the request body will be updated — everything else stays
unchanged. This is called a "partial update" pattern.
"""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
price: Optional[float] = Field(None, gt=0)
category: Optional[str] = Field(None, min_length=1, max_length=50)
class ItemResponse(BaseModel):
"""
Schema for item responses.
This model defines what the API returns. It includes
server-generated fields like item_id and timestamps
that aren't part of the create/update requests.
"""
item_id: str
name: str
description: Optional[str] = None
price: float
category: str
created_at: str
updated_at: str
Why Three Separate Models?
You might wonder why we have ItemCreate, ItemUpdate, and ItemResponse instead of one Item model. Each serves a different purpose:
- ItemCreate — Used for
POSTrequests. All required fields must be present. You can’t create an item without a name and price. - ItemUpdate — Used for
PUTrequests. All fields areOptional. If you only want to update the price, you send{"price": 39.99}and everything else stays the same. - ItemResponse — Used for API responses. Includes server-generated fields (
item_id,created_at,updated_at) that the client never sends.
This pattern prevents a common bug: accidentally wiping out existing data during an update because a field was missing from the request.
What Happens When Validation Fails?
If someone sends an invalid request like {"name": "", "price": -5}, FastAPI automatically returns a 422 Unprocessable Entity response:
{
"detail": [
{
"loc": ["body", "name"],
"msg": "String should have at least 1 character",
"type": "string_too_short"
},
{
"loc": ["body", "price"],
"msg": "Input should be greater than 0",
"type": "greater_than"
}
]
}
You didn’t write any of that error handling — FastAPI generated it from the Pydantic model constraints. This is one of the biggest time-savers when building APIs.
Step 2: Build the DynamoDB Service Layer
Next, let’s create the service that handles all database operations. We’re isolating this in its own file so our route handlers stay clean and focused on HTTP logic.
# src/services/dynamodb.py
import boto3
import uuid
from datetime import datetime, timezone
from typing import Optional
from boto3.dynamodb.conditions import Key
class DynamoDBService:
"""
Service class for DynamoDB operations.
This class encapsulates all database logic. If you ever
need to switch from DynamoDB to PostgreSQL or MongoDB,
you only change this file — routes stay untouched.
"""
def __init__(self, table_name: str = "Items"):
self.dynamodb = boto3.resource("dynamodb")
self.table = self.dynamodb.Table(table_name)
def create_item(self, item_data: dict) -> dict:
"""
Create a new item in DynamoDB.
Generates a UUID for item_id and adds timestamps.
Uses put_item which will overwrite if the same
item_id exists (extremely unlikely with UUIDs).
"""
now = datetime.now(timezone.utc).isoformat()
item = {
"item_id": str(uuid.uuid4()),
"created_at": now,
"updated_at": now,
**item_data # Spread the validated data from Pydantic
}
try:
self.table.put_item(Item=item)
return item
except Exception as e:
raise Exception(f"Failed to create item: {str(e)}")
def get_item(self, item_id: str) -> Optional[dict]:
"""
Retrieve a single item by its ID.
Returns None if the item doesn't exist, which
lets the route handler return a proper 404.
"""
try:
response = self.table.get_item(Key={"item_id": item_id})
return response.get("Item") # Returns None if not found
except Exception as e:
raise Exception(f"Failed to get item: {str(e)}")
def get_all_items(self) -> list:
"""
Retrieve all items using scan().
WARNING: scan() reads every item in the table and is
expensive on large datasets. For production with 10K+
items, add a Global Secondary Index (GSI) and use
query() instead. Fine for small datasets.
Handles pagination automatically — DynamoDB returns
max 1MB per scan, so we loop until all items are fetched.
"""
try:
response = self.table.scan()
items = response.get("Items", [])
# Handle pagination for large datasets
while "LastEvaluatedKey" in response:
response = self.table.scan(
ExclusiveStartKey=response["LastEvaluatedKey"]
)
items.extend(response.get("Items", []))
return items
except Exception as e:
raise Exception(f"Failed to scan items: {str(e)}")
def update_item(self, item_id: str, update_data: dict) -> Optional[dict]:
"""
Update an existing item. Only updates provided fields.
Uses DynamoDB's UpdateExpression to modify individual
attributes without replacing the entire item.
The ConditionExpression ensures we get a clear error
if the item doesn't exist, rather than silently creating
a new partial item.
"""
# Filter out None values — only update fields that were provided
update_data = {k: v for k, v in update_data.items() if v is not None}
if not update_data:
return self.get_item(item_id)
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
# Build update expression dynamically
# e.g., "SET #name = :name, #price = :price, #updated_at = :updated_at"
update_expr = "SET " + ", ".join(f"#{k} = :{k}" for k in update_data)
# We use expression attribute NAMES (#name) because some field
# names like "name" and "status" are DynamoDB reserved words
expr_names = {f"#{k}": k for k in update_data}
expr_values = {f":{k}": v for k, v in update_data.items()}
try:
response = self.table.update_item(
Key={"item_id": item_id},
UpdateExpression=update_expr,
ExpressionAttributeNames=expr_names,
ExpressionAttributeValues=expr_values,
ReturnValues="ALL_NEW", # Return the complete updated item
ConditionExpression="attribute_exists(item_id)"
)
return response.get("Attributes")
except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
return None # Item doesn't exist
except Exception as e:
raise Exception(f"Failed to update item: {str(e)}")
def delete_item(self, item_id: str) -> bool:
"""
Delete an item by ID.
Returns True if deleted, False if item didn't exist.
The ConditionExpression prevents silent "success" when
deleting a non-existent item.
"""
try:
self.table.delete_item(
Key={"item_id": item_id},
ConditionExpression="attribute_exists(item_id)"
)
return True
except self.dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
return False
except Exception as e:
raise Exception(f"Failed to delete item: {str(e)}")
Why ConditionExpression Matters
Without ConditionExpression="attribute_exists(item_id)", DynamoDB’s delete_item and update_item succeed silently even when the item doesn’t exist. This can lead to confusing bugs where your API returns 200 OK for deleting an item that was never there.
With the condition, DynamoDB throws a ConditionalCheckFailedException when the item doesn’t exist, which we catch and convert into a False return (for delete) or None (for update). The route handler then returns a proper 404.
Step 3: Create the API Routes
Now let’s wire up the FastAPI routes. Notice how thin these handlers are — they just validate input, call the service, and return a response:
# src/routes/items.py
from fastapi import APIRouter, HTTPException
from typing import List
from ..models.item import ItemCreate, ItemUpdate, ItemResponse
from ..services.dynamodb import DynamoDBService
router = APIRouter(prefix="/items", tags=["Items"])
db = DynamoDBService()
@router.post("/", response_model=ItemResponse, status_code=201)
def create_item(item: ItemCreate):
"""
Create a new item.
FastAPI automatically:
1. Parses the JSON request body
2. Validates it against ItemCreate model
3. Returns 422 if validation fails
4. Passes the validated data to this function
"""
try:
result = db.create_item(item.model_dump())
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=List[ItemResponse])
def get_all_items():
"""Retrieve all items."""
try:
items = db.get_all_items()
return items
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{item_id}", response_model=ItemResponse)
def get_item(item_id: str):
"""
Retrieve a single item by ID.
The item_id comes from the URL path, e.g., /items/abc-123
FastAPI extracts it automatically from the path parameter.
"""
try:
item = db.get_item(item_id)
if not item:
raise HTTPException(
status_code=404,
detail=f"Item {item_id} not found"
)
return item
except HTTPException:
raise # Re-raise HTTP exceptions as-is
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{item_id}", response_model=ItemResponse)
def update_item(item_id: str, item: ItemUpdate):
"""
Update an existing item.
Only the fields you include in the request body will be
updated. For example, sending {"price": 39.99} only updates
the price — name, description, and category stay unchanged.
"""
try:
result = db.update_item(item_id, item.model_dump())
if not result:
raise HTTPException(
status_code=404,
detail=f"Item {item_id} not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{item_id}", status_code=204)
def delete_item(item_id: str):
"""
Delete an item by ID.
Returns 204 No Content on success (no response body).
Returns 404 if the item doesn't exist.
"""
try:
success = db.delete_item(item_id)
if not success:
raise HTTPException(
status_code=404,
detail=f"Item {item_id} not found"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
The Error Handling Pattern Explained
Notice this pattern in every route:
try:
# ... business logic ...
except HTTPException:
raise # Re-raise our own 404s and other HTTP errors
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
Why re-raise HTTPException? Without the except HTTPException: raise line, our 404 errors would be caught by the generic except Exception block and converted into 500 errors. That’s confusing — a missing item should be a 404, not a 500.
Why catch all other exceptions as 500? This prevents your API from leaking internal error details (like database connection strings or stack traces) to API consumers. In production, you’d log the full error server-side and return a generic message to the client.
Step 4: Create the FastAPI App with Mangum
Here’s the entry point that ties everything together. The key piece is Mangum — the adapter that makes FastAPI work on AWS Lambda:
# src/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from mangum import Mangum
from .routes import items
# Create the FastAPI application
app = FastAPI(
title="DriveDataScience API",
description="A REST API built with FastAPI and deployed on AWS Lambda",
version="1.0.0",
docs_url="/docs", # Swagger UI at /docs
redoc_url="/redoc", # ReDoc at /redoc
)
# CORS middleware — required if your frontend is on a different domain
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace with your domain
allow_credentials=True,
allow_methods=["*"], # Allow all HTTP methods
allow_headers=["*"], # Allow all headers
)
# Include the items router with /api/v1 prefix
app.include_router(items.router, prefix="/api/v1")
@app.get("/")
def health_check():
"""Health check endpoint — useful for monitoring."""
return {
"status": "healthy",
"service": "fastapi-lambda-api",
"version": "1.0.0"
}
# This is the Lambda handler — Mangum translates between
# API Gateway events and FastAPI's ASGI interface
handler = Mangum(app, lifespan="off")
What Does Mangum Actually Do?
When API Gateway receives an HTTP request, it converts it into a Lambda event — a Python dictionary with keys like httpMethod, path, headers, body, etc.
FastAPI doesn’t understand Lambda events. It speaks ASGI (Asynchronous Server Gateway Interface) — the standard protocol for Python async web frameworks.
Mangum sits between them:
- Receives the Lambda event from API Gateway
- Translates it into an ASGI request that FastAPI understands
- Passes it to your FastAPI app
- Takes FastAPI’s ASGI response
- Converts it back into the format API Gateway expects
- Returns it to the client
All of this happens in a single line: handler = Mangum(app). That’s it. No configuration needed.
Why lifespan="off"?
FastAPI supports lifespan events (startup/shutdown hooks). Lambda functions don’t have a traditional lifecycle — they start, handle one (or a few) requests, and might be frozen or destroyed. Setting lifespan="off" prevents potential issues with startup/shutdown code that assumes a long-running server.
Step 5: Define the SAM Template
AWS SAM (Serverless Application Model) is an Infrastructure-as-Code tool that defines your Lambda function, API Gateway, and DynamoDB table in a single YAML file:
# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: FastAPI REST API on AWS Lambda
Globals:
Function:
Timeout: 30 # Max execution time in seconds
MemorySize: 256 # MB of RAM allocated to the function
Runtime: python3.11
Architectures:
- x86_64
Resources:
# DynamoDB Table
ItemsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: Items
BillingMode: PAY_PER_REQUEST # No capacity planning needed
AttributeDefinitions:
- AttributeName: item_id
AttributeType: S # S = String
KeySchema:
- AttributeName: item_id
KeyType: HASH # Partition key
# Lambda Function
FastAPIFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src.main.handler # Points to the Mangum handler
CodeUri: . # Upload entire project directory
Description: FastAPI Lambda function
Events:
# Catch-all route — forwards ALL requests to FastAPI
ApiCatchAll:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
# Root path needs its own route
ApiRoot:
Type: HttpApi
Properties:
Path: /
Method: ANY
Policies:
# SAM shorthand — grants full CRUD access to the table
- DynamoDBCrudPolicy:
TableName: !Ref ItemsTable
Environment:
Variables:
TABLE_NAME: !Ref ItemsTable
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
Key Points About This Template
PAY_PER_REQUEST billing: DynamoDB offers two billing modes. With PAY_PER_REQUEST, you pay only for the read/write operations you actually use — no need to estimate capacity. Perfect for new projects where you don’t know your traffic patterns yet.
HttpApi vs Api: SAM supports two API Gateway types. HttpApi is the newer HTTP API — it’s cheaper ($1.00 vs $3.50 per million requests), faster, and supports JWT authorizers. Use HttpApi for new projects.
{proxy+} route: This is a greedy path parameter that forwards ALL requests to your Lambda function. FastAPI then handles its own routing internally. This means you define routes only in Python, not in the SAM template.
DynamoDBCrudPolicy: A SAM policy shorthand that creates an IAM policy granting PutItem, GetItem, DeleteItem, UpdateItem, Query, and Scan permissions on the specified table.
Step 6: Set Up Dependencies
Create a requirements.txt with the minimum dependencies:
fastapi==0.115.0
mangum==0.19.0
pydantic==2.9.0
boto3==1.35.0
Note: boto3 is actually pre-installed in the Lambda runtime, but including it in requirements.txt ensures version consistency. For a smaller deployment package, you can remove it — Lambda will use its built-in version.
Step 7: Deploy
Deployment with SAM is just two commands:
# Step 1: Build the Lambda deployment package
# This installs dependencies and creates a .aws-sam/ directory
sam build
# Step 2: Deploy to AWS (first time — interactive mode)
sam deploy --guided
The --guided flag walks you through the configuration:
Stack Name [sam-app]: fastapi-items-api
AWS Region [us-east-1]: us-east-1
Confirm changes before deploy [y/N]: y
Allow SAM CLI IAM role creation [Y/n]: Y
Disable rollback [y/N]: N
Save arguments to configuration file [Y/n]: Y
After deployment completes (usually 2-3 minutes), you’ll see the API URL in the outputs:
Outputs
---------------------------------------------------------------------------
Key ApiUrl
Description API Gateway endpoint URL
Value https://abc123xyz.execute-api.us-east-1.amazonaws.com/
---------------------------------------------------------------------------
For subsequent deployments, just run:
sam build && sam deploy
Step 8: Test Your API
Let’s verify everything works. Replace YOUR_API_URL with the URL from the deployment output:
# 1. Health check
curl https://YOUR_API_URL/
# Expected response:
# {"status":"healthy","service":"fastapi-lambda-api","version":"1.0.0"}
# 2. Create an item
curl -X POST https://YOUR_API_URL/api/v1/items/ \
-H "Content-Type: application/json" \
-d '{
"name": "Python Crash Course",
"description": "A hands-on introduction to Python",
"price": 29.99,
"category": "Books"
}'
# Expected: 201 Created with the full item including item_id
# 3. List all items
curl https://YOUR_API_URL/api/v1/items/
# Expected: Array of all items
# 4. Get a specific item (use the item_id from step 2)
curl https://YOUR_API_URL/api/v1/items/ITEM_ID_HERE
# 5. Update an item (partial update — only changes the price)
curl -X PUT https://YOUR_API_URL/api/v1/items/ITEM_ID_HERE \
-H "Content-Type: application/json" \
-d '{"price": 39.99}'
# Expected: Updated item with new price, everything else unchanged
# 6. Delete an item
curl -X DELETE https://YOUR_API_URL/api/v1/items/ITEM_ID_HERE
# Expected: 204 No Content
# 7. Visit the auto-generated docs
# Open in browser: https://YOUR_API_URL/docs
That last URL — the /docs endpoint — is pure FastAPI magic. You get a fully interactive Swagger UI where you can test every endpoint directly from the browser. No Postman needed.
Understanding the Request Flow
Here’s what happens when a request hits your API, step by step:
- Client sends
POST /api/v1/items/with a JSON body - API Gateway receives the HTTPS request and triggers the Lambda function
- Lambda starts your function (cold start on first request) and passes the event to the handler
- Mangum translates the Lambda event into an ASGI request
- FastAPI matches the route to
create_item()initems.py - Pydantic validates the request body against
ItemCreatemodel - If validation fails → FastAPI returns 422 immediately (steps 8-10 are skipped)
- Route handler calls
db.create_item()with the validated data - DynamoDB stores the item and returns success
- FastAPI serializes the response using
ItemResponsemodel - Mangum converts the ASGI response back to Lambda format
- API Gateway returns the HTTP response to the client
The entire round-trip typically takes 50-200ms (or 1-3 seconds on a cold start).
Adding Authentication with Cognito
For a production API, you’ll want authentication. Here’s a high-level overview of how to add AWS Cognito:
- Create a Cognito User Pool in the AWS Console
- Create an App Client in the User Pool
- Add a JWT authorizer to your API Gateway in the SAM template:
FastAPIFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src.main.handler
CodeUri: .
Events:
ApiCatchAll:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
Auth:
Authorizer: CognitoAuthorizer
# Define the authorizer
ServerlessHttpApi:
Type: AWS::Serverless::HttpApi
Properties:
Auth:
Authorizers:
CognitoAuthorizer:
AuthorizationScopes:
- email
IdentitySource: $request.header.Authorization
JwtConfiguration:
issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
audience:
- !Ref UserPoolClient
- Clients include the JWT token in the
Authorizationheader:
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
https://YOUR_API_URL/api/v1/items/
I’ll cover this in detail in a future post about Securing REST APIs with AWS Cognito.
Lessons from Production
After running this stack in production for my expense tracker app, here are the most important things I learned:
1. Cold Starts Are Real — But Manageable
Lambda cold starts add 1-3 seconds on the first request after your function has been idle. This happens because AWS needs to provision a container, load your code, and initialize the Python runtime.
Mitigation strategies: – Provisioned Concurrency: Keep N instances warm at all times (~$15/month per instance) – Warm-up events: Schedule a CloudWatch event to ping your function every 5 minutes – Minimize package size: Smaller deployment packages = faster cold starts. Remove unnecessary dependencies. – Use x86_64 over arm64 for Python if cold start matters — currently slightly faster initialization
2. DynamoDB scan() Gets Expensive Fast
The get_all_items() endpoint uses scan(), which reads every single item in the table. This is fine for 100 items but becomes slow and costly at 100,000 items.
Better approach for production:
– Add a Global Secondary Index (GSI) on category or created_at
– Use query() instead of scan() — it reads only the items matching your query
– Add pagination to the list endpoint (return 20 items at a time with a cursor)
3. API Gateway Has a 30-Second Timeout
If your Lambda function takes longer than 30 seconds, API Gateway returns a 504 Gateway Timeout. This is a hard limit — you can’t increase it.
Solution: Design your endpoints to be fast. For heavy processing (image resizing, report generation, data imports), use an async pattern: accept the request, push it to an SQS queue, process it in a separate Lambda, and let the client poll for results.
4. Always Version Your API
We used /api/v1/ as a prefix. This might seem unnecessary now, but it saves you when you need breaking changes. You add /api/v2/ routes alongside the old ones, migrate clients gradually, and eventually deprecate v1.
5. Never Hardcode AWS Resource Names
Notice how we use environment variables in the SAM template:
Environment:
Variables:
TABLE_NAME: !Ref ItemsTable
This makes your code portable across environments. The same code runs against Items-dev, Items-staging, and Items-prod tables — you just change the environment variable.
Common Mistakes to Avoid
Based on real debugging sessions, here are mistakes that trip up most developers:
- Forgetting to include
__init__.pyfiles — Without these, Python can’t find your modules when Lambda runs your code - Using
Apiinstead ofHttpApiin SAM —Apicreates the older, more expensive REST API. UseHttpApifor new projects. - Not handling DynamoDB Decimal type — DynamoDB stores numbers as
Decimal, but JSON doesn’t supportDecimal. Usejson.loads(json.dumps(item, default=str))for serialization. - Missing CORS middleware — If your frontend gets “CORS error”, you forgot to add the
CORSMiddleware. - Deploying without testing locally — Use
sam local start-apito test your API locally before deploying to AWS.
Cost Breakdown
Here’s what this entire stack costs for a typical small to medium API:
| Service | Free Tier (Monthly) | After Free Tier |
|---|---|---|
| Lambda | 1M requests + 400K GB-sec | $0.20 per 1M requests |
| API Gateway (HTTP) | 1M requests for 12 months | $1.00 per 1M requests |
| DynamoDB | 25 GB storage + 25 WCU/RCU | $0.25 per GB/month |
| CloudWatch Logs | 5 GB ingestion | $0.50 per GB |
| Total for ~10K requests/day | $0 | ~$2-3/month |
For a personal project, blog API, or early-stage startup, you’ll likely stay within the free tier for the first 12 months. Even after that, you’re looking at a few dollars per month — compared to $5-20/month for a traditional server.
Wrapping Up
You now have a complete, production-ready REST API running on AWS Lambda. Let’s recap what we covered:
- FastAPI for a modern, type-safe Python web framework with auto-generated docs
- Pydantic models for request validation and API contracts
- DynamoDB for fully managed, serverless data storage
- Mangum for adapting FastAPI to run on Lambda
- AWS SAM for defining and deploying the entire infrastructure
- Real production lessons including cold starts, scan costs, and API versioning
This same architecture powers real production applications. You can extend it with authentication (Cognito), file uploads (S3), background jobs (SQS), notifications (SNS), and more — all without managing a single server.
The full source code for this project is available on GitHub (link coming soon).
What to read next: – SQL Window Functions Explained: ROW_NUMBER, RANK, DENSE_RANK, LAG (coming soon) – Metadata-Driven Pipelines in Azure Data Factory (coming soon) – Securing REST APIs with AWS Cognito (coming soon)
If this guide helped you, share it with a fellow developer. Have questions? Drop a comment below.
Naveen Vuppula is a Senior Data Engineering Consultant and app developer based in Ontario, Canada. He writes about Python, SQL, AWS, Azure, and everything data engineering at DriveDataScience.com.