Migrate a .NET Task API to Python
Migrate a .NET-style REST API to Python and deploy it to AWS Lambda.
Introduction
30 Second Summary
Organizations everywhere are migrating legacy .NET services to Python for faster iteration, lower infrastructure costs, and access to modern AI/ML tooling. If you can prove you know how to translate enterprise patterns from one language to another and deploy the result to the cloud, you stand out.
In this project, you will build a Task Management REST API in Python that mirrors .NET architectural patterns, then deploy it serverlessly to AWS Lambda with a live public URL anyone can call.
What You'll Build
A serverless Task Management API deployed to AWS Lambda, built with FastAPI using enterprise Python patterns that map directly to .NET architecture.
By the end of this project, you'll have:
- A FastAPI application with full CRUD endpoints for tasks, using Pydantic models that replace .NET DTOs and data annotations.
- A SAM template defining your Lambda function and API Gateway as infrastructure-as-code.
- A live public API endpoint deployed to AWS that anyone can call, with measured and optimized cold start performance.
- Secret Mission: Add response caching and request logging to demonstrate performance optimization skills.
Are there any prerequisites?
You need basic Python knowledge (functions, classes, imports) and an AWS account with admin access. You should also have Python and Docker installed on your machine. Step 1 covers installing the AWS CLI and AWS SAM CLI.
Install AWS CLI and SAM CLI
Your API will live on AWS Lambda, a serverless compute service that runs your code without managing servers. To get it there, you need two command-line tools: the AWS CLI for configuring your credentials, and the AWS SAM CLI for building, testing, and deploying your serverless application.
You will also create the project directory structure that mirrors a clean Python package layout. This structure keeps your API code organized the same way a .NET solution separates controllers, models, and configuration into distinct files.
In this step, get ready to:
- Install and configure the AWS CLI with your credentials.
- Install the AWS SAM CLI for serverless deployments.
- Create the project folder structure with all required files.
Install the AWS CLI
The AWS CLI lets you interact with AWS services from your terminal. You need it to store your credentials so that SAM can deploy resources on your behalf.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
Install the AWS CLI using the official installer for your platform.
macOS
- Download and install the AWS CLI by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What do these commands do?
- The first command downloads the official AWS CLI installer package from Amazon.
- The second command runs the macOS package installer, which places the AWS CLI in /usr/local/aws-cli and creates a symlink at /usr/local/bin/aws.
macOS prompts you for your password
The sudo command requires your Mac login password. Type it and press Enter. You will not see characters as you type.
Windows
- Download the AWS CLI MSI installer from this URL:
https://awscli.amazonaws.com/AWSCLIV2.msi
- Open the downloaded AWSCLIV2.msi file.
- Follow the on-screen prompts, accepting the defaults at each step.
- Click Install, then Finish when the installation completes.
Close and reopen PowerShell
After installing, close your PowerShell window and open a new one so it picks up the updated PATH.
- Verify the installation by running:
aws --version
You should see output like aws-cli/2.x.x Python/3.x.x followed by your operating system details.
Command not found?
- On Windows, make sure you opened a new PowerShell window after installing.
- On macOS, confirm the installer finished without errors and try running /usr/local/bin/aws --version directly.
- help me troubleshoot why aws --version is not working
Now configure the CLI with your AWS credentials. The aws configure command will prompt you for four values in sequence.
- Run the configuration command:
aws configure
What will aws configure ask me?
The CLI will prompt you for four values, one at a time. Enter each value and press Enter:
- AWS Access Key ID - paste your IAM access key ID.
- AWS Secret Access Key - paste your IAM secret access key.
- Default region name - type us-east-1.
- Default output format - type json.
Where do I find my Access Key?
In the AWS Console, go to IAM, select your user, open the Security credentials tab, and create an access key. Copy the Access Key ID and Secret Access Key. You will only see the secret key once, so save it somewhere safe.
Not sure if it worked?
Run aws sts get-caller-identity to confirm your credentials are set up correctly. You should see your account ID, user ARN, and user ID in the output.
help me verify my AWS CLI credentials are configured correctly
Install the AWS SAM CLI
The SAM CLI is the tool that packages your Python code and deploys it to Lambda as infrastructure-as-code. It can also run a local API Gateway for testing before you deploy.
macOS
AWS recommends using their official package installer for macOS.
- Download and install the SAM CLI by running these commands:
curl -Lo aws-sam-cli-macos-arm64.pkg https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-macos-arm64.pkg
sudo installer -pkg aws-sam-cli-macos-arm64.pkg -target /
Intel Mac?
If your Mac has an Intel processor, replace arm64 with x86_64 in both places in the command above. To check, click the Apple menu, then About This Mac. If it says Apple M1/M2/M3, use arm64.
Windows
- Download the AWS SAM CLI 64-bit MSI installer from this URL:
https://github.com/aws/aws-sam-cli/releases/latest/download/AWS_SAM_CLI_64_PY3.msi
- Open the downloaded AWS_SAM_CLI_64_PY3.msi file.
- Follow the on-screen prompts, accepting the defaults at each step.
- Click Install, then Finish when complete.
- Close and reopen your PowerShell window.
- Verify the SAM CLI installation by running:
sam --version
You should see output like SAM CLI, version 1.x.x.
sam command not found?
- Make sure you opened a brand new terminal window after the installer finished.
- On Windows, try running the installer again as Administrator (right-click the MSI, select Run as administrator).
- help me fix SAM CLI not being found after installation
One more configuration step for Windows users. SAM creates deeply nested file paths during builds, which can hit the Windows 260-character path limit.
macOS
No extra configuration needed. macOS does not have the path length limit that Windows has.
Windows
- Enable long paths by running this command in PowerShell as Administrator:
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
What does this command do?
This sets a Windows registry key that removes the 260-character file path limit. Without it, SAM builds can fail with cryptic file-not-found errors when dependency paths get too long.
Create the project structure
Your project needs a root folder for the SAM template and a Python package folder for the API code. This mirrors how a .NET solution separates the project file from the source code directory.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir task-api-project
cd task-api-project
- Create the task_api package folder by running:
mkdir task_api
Now create the empty placeholder files that you will fill in during later steps.
macOS
- Create the root template file and all package files by running:
touch template.yaml
touch task_api/__init__.py task_api/main.py task_api/models.py task_api/routes.py task_api/requirements.txt
Windows
- Create the root template file and all package files by running:
New-Item template.yaml
New-Item task_api/__init__.py
New-Item task_api/main.py
New-Item task_api/models.py
New-Item task_api/routes.py
New-Item task_api/requirements.txt
- Confirm the files exist by running:
ls task_api
You should see __init__.py, main.py, models.py, requirements.txt, and routes.py listed in the output.
Missing files?
- Make sure you ran the mkdir task_api command before creating the files inside it.
- On Windows, PowerShell may show a confirmation for each file. This is normal.
- help me fix my project directory structure
What is each file for?
- template.yaml - the SAM infrastructure template that defines your Lambda function and API Gateway.
- __init__.py - marks task_api/ as a Python package (like a .NET namespace).
- main.py - the application entry point (like Program.cs in .NET).
- models.py - Pydantic data models (replacing .NET DTOs).
- routes.py - API endpoints (replacing .NET Controllers).
- requirements.txt - Python dependencies (like a .csproj PackageReference section).
Your environment is ready. Next up, you will build the data models that define what a task looks like in your API.
Build the Data Models
Your tools are installed and your project folder is ready. Now it's time to define the data structures that your API will use to validate and shape task information.
In .NET, you define Data Transfer Objects (DTOs) as C# classes with data annotations like [Required] and [MaxLength]. In Python, Pydantic models serve the exact same purpose. They define what data looks like, enforce validation rules, and reject anything that doesn't match.
In this step, get ready to:
- Define Pydantic models that validate incoming task data.
- Create an in-memory data store and a unique ID generator for tasks.
Define Pydantic models in models.py
Your API needs three data shapes. One for creating tasks, one for updating them, and one for returning task data to the caller.
What are Pydantic models?
Pydantic models are Python classes that define the exact shape and rules for your data. If someone sends a task with a title longer than 200 characters, Pydantic automatically rejects it before your code even runs.
In .NET terms, a Pydantic BaseModel is your DTO class. The Field() function replaces data annotations like [Required] and [MaxLength(200)]. Python type hints replace C# type declarations.
- Open task_api/models.py in your code editor and add the imports and the TaskCreate model by pasting this code:
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
import uuid
# Model for creating a new task (validates incoming POST data)
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
is_completed: bool = False
What does this code do?
- The imports bring in BaseModel and Field from Pydantic for model definitions, Optional for nullable fields, datetime for timestamps, and uuid for generating unique IDs.
- Field(..., min_length=1, max_length=200) makes title required (the ... means "no default, must be provided") and restricts it to 1-200 characters.
- Optional[str] with Field(None, ...) means description can be left out entirely. In .NET, this is equivalent to a nullable string property.
You won't see a change yet. The next chunk adds the remaining models that complete the data layer.
- Now add the TaskUpdate and TaskResponse models below the TaskCreate class you just wrote:
# Model for updating an existing task (all fields optional)
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
is_completed: Optional[bool] = None
# Model for returning task data to the caller (the response shape)
class TaskResponse(BaseModel):
id: str
title: str
description: Optional[str]
is_completed: bool
created_at: str
updated_at: str
What does this code do?
- TaskUpdate makes every field optional. This lets callers update just one field (like marking a task complete) without resending the entire object. In .NET, this is a separate DTO specifically for PATCH/PUT operations.
- TaskResponse defines what the API sends back. It includes system-generated fields like id, created_at, and updated_at that the caller never provides. In .NET, this is your response ViewModel.
You won't see a change yet. The next section adds the data store that these models will work with.
Create an in-memory data store
Your models define the shape of task data, but you also need somewhere to store tasks. A Python dictionary works perfectly as a lightweight stand-in for a database.
- Add the data store and ID generator at the bottom of task_api/models.py, below the TaskResponse class:
# In-memory data store (simulates a database)
tasks_db: dict[str, dict] = {}
def generate_task_id() -> str:
return str(uuid.uuid4())
What does this code do?
- tasks_db is a dictionary that maps task IDs (strings) to task data (dictionaries). It lives in memory, so it resets each time Lambda starts a new container. For a production app, you would swap this for a database like DynamoDB.
- generate_task_id() returns a UUID (universally unique identifier) as a string. This guarantees every task gets a unique ID, even across multiple Lambda containers.
- Save task_api/models.py.
You can't test the models in isolation yet because the API routes haven't been built. The next step wires these models into working endpoints.
Seeing red squiggles in your editor?
If your editor shows import errors for pydantic, that is expected. You have not installed the dependencies locally yet. SAM handles dependency installation during the build process in Step 4. The code is still correct.
If you want to suppress the warnings now, you can run pip install pydantic in your terminal. This is optional.
Still stuck? Help me fix pydantic import errors in my models.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved models.py and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete task_api/models.py file. Make sure yours matches exactly.
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
import uuid
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
is_completed: bool = False
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
is_completed: Optional[bool] = None
class TaskResponse(BaseModel):
id: str
title: str
description: Optional[str]
is_completed: bool
created_at: str
updated_at: str
# In-memory data store (simulates a database)
tasks_db: dict[str, dict] = {}
def generate_task_id() -> str:
return str(uuid.uuid4())
Your data models are locked in. Next up, you will build the API routes that use these models to create, read, update, and delete tasks.
Build the API Routes
Your data models are in place. Now you need the actual endpoints that receive HTTP requests, process them, and return responses.
In .NET, you would create a Controller class with action methods for each endpoint. In FastAPI, the equivalent is an APIRouter that groups related endpoints together. In this step, you will build five CRUD endpoints and wire them into your application entry point.
In this step, get ready to:
- Create a task router with five CRUD endpoints in routes.py.
- Wire the router into main.py and add the Lambda-compatible handler.
Create the task router in routes.py
A .NET Controller groups related action methods under one class. FastAPI's APIRouter does the same thing. You create a router, attach endpoint functions to it, and later include it in the main app.
- Open task_api/routes.py in your editor and add the imports, router instance, and the first endpoint by pasting the following code:
from fastapi import APIRouter, HTTPException
from datetime import datetime, timezone
from .models import TaskCreate, TaskUpdate, TaskResponse, tasks_db, generate_task_id
# Create a router that groups all task endpoints (like a .NET Controller)
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
# Generate a unique ID and timestamp for the new task
task_id = generate_task_id()
now = datetime.now(timezone.utc).isoformat()
task_data = {
"id": task_id,
"title": task.title,
"description": task.description,
"is_completed": task.is_completed,
"created_at": now,
"updated_at": now,
}
# Store the task in our in-memory database
tasks_db[task_id] = task_data
return TaskResponse(**task_data)
What does this code do?
The APIRouter(prefix="/tasks") creates a route group. Every endpoint you attach will automatically start with /tasks. This is the Python equivalent of a .NET Controller with a [Route("api/tasks")] attribute.
The create_task function accepts a TaskCreate model as input. FastAPI automatically validates the incoming JSON against your Pydantic model before the function body runs. If validation fails, it returns a 422 error with details.
How does this map to .NET?
- .NET: [HttpPost] public ActionResult<TaskDto> Create(CreateTaskDto task)
- Python: @router.post("/", response_model=TaskResponse)
The decorator handles HTTP method binding and response serialization, just like .NET attributes do.
- Save routes.py.
You won't see a change yet. The next chunk adds the read endpoints that you will test together.
- Below the create_task function, add the two GET endpoints by pasting the following code:
@router.get("/", response_model=list[TaskResponse])
def get_all_tasks():
# Return all tasks from the in-memory store
return [TaskResponse(**task) for task in tasks_db.values()]
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: str):
# Look up a single task by ID, return 404 if not found
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**tasks_db[task_id])
What does this code do?
get_all_tasks returns every task in the store as a list. The response_model=list[TaskResponse] tells FastAPI to serialize the output as a JSON array.
get_task retrieves a single task by its ID from the URL path. If the ID does not exist, it raises an HTTPException with a 404 status code. This is FastAPI's equivalent of returning NotFound() in a .NET controller.
- Save routes.py.
You won't see a change yet. The next chunk adds the final two endpoints to complete your CRUD operations.
- Below the get_task function, add the PUT and DELETE endpoints by pasting the following code:
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: str, task_update: TaskUpdate):
# Check the task exists before updating
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
task_data = tasks_db[task_id]
# Only update fields that were explicitly sent in the request
update_fields = task_update.model_dump(exclude_unset=True)
for key, value in update_fields.items():
task_data[key] = value
task_data["updated_at"] = datetime.now(timezone.utc).isoformat()
tasks_db[task_id] = task_data
return TaskResponse(**task_data)
@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: str):
# Remove the task from the store, return 404 if not found
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
del tasks_db[task_id]
return None
What does this code do?
update_task uses model_dump(exclude_unset=True) to get only the fields the client explicitly included in the request body. This means a client can update just the title without accidentally clearing the description.
delete_task returns status code 204 (No Content) on success. This is the standard REST convention for successful deletions where no response body is needed.
- Save routes.py.
Your routes file is now complete with all five CRUD endpoints. You will test them once the app is wired together in the next substep.
Seeing a red underline on .models?
The relative import from .models import ... may show a warning in your editor because the app is not running yet. This is normal and will resolve once the package is used as a module.
Make sure __init__.py exists in the task_api/ folder. Without it, Python does not recognize the folder as a package.
help me fix my relative import error in routes.py
✔️ Awesome, I've got everything!
Double check you have saved routes.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete task_api/routes.py file for comparison.
from fastapi import APIRouter, HTTPException
from datetime import datetime, timezone
from .models import TaskCreate, TaskUpdate, TaskResponse, tasks_db, generate_task_id
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
task_id = generate_task_id()
now = datetime.now(timezone.utc).isoformat()
task_data = {
"id": task_id,
"title": task.title,
"description": task.description,
"is_completed": task.is_completed,
"created_at": now,
"updated_at": now,
}
tasks_db[task_id] = task_data
return TaskResponse(**task_data)
@router.get("/", response_model=list[TaskResponse])
def get_all_tasks():
return [TaskResponse(**task) for task in tasks_db.values()]
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(task_id: str):
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse(**tasks_db[task_id])
@router.put("/{task_id}", response_model=TaskResponse)
def update_task(task_id: str, task_update: TaskUpdate):
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
task_data = tasks_db[task_id]
update_fields = task_update.model_dump(exclude_unset=True)
for key, value in update_fields.items():
task_data[key] = value
task_data["updated_at"] = datetime.now(timezone.utc).isoformat()
tasks_db[task_id] = task_data
return TaskResponse(**task_data)
@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: str):
if task_id not in tasks_db:
raise HTTPException(status_code=404, detail="Task not found")
del tasks_db[task_id]
return None
Wire the router into main.py and add the Mangum handler
Your endpoints exist in routes.py, but nothing is serving them yet. You need an application entry point that registers the router and exposes a handler that AWS Lambda can invoke.
- Open task_api/main.py and paste the following code:
from fastapi import FastAPI
from mangum import Mangum
from .routes import router
# Create the FastAPI application instance
app = FastAPI(
title="Task Management API",
description="A .NET-style task API migrated to Python with FastAPI",
version="1.0.0",
)
# Register the task router (like adding a Controller in .NET Startup.cs)
app.include_router(router)
@app.get("/")
def root():
# Health check endpoint at the API root
return {"message": "Task Management API is running", "version": "1.0.0"}
# Mangum wraps the ASGI app so Lambda can invoke it
handler = Mangum(app, lifespan="off")
What does this code do?
- FastAPI() creates your application instance. The title, description, and version fields populate the auto-generated API docs.
- app.include_router(router) attaches all your task endpoints to the app. This is the Python equivalent of registering controllers in .NET's Startup.cs.
- Mangum(app, lifespan="off") wraps your FastAPI app so AWS Lambda can call it. Lambda does not support long-running processes, so lifespan="off" disables startup/shutdown lifecycle events.
What is Mangum?
Mangum is a lightweight adapter that translates AWS Lambda events into ASGI requests that FastAPI understands. Without it, Lambda would not know how to pass HTTP requests to your Python app.
- Save main.py.
You cannot test the API locally yet because SAM has not been configured. The next step sets up the infrastructure template and local testing.
Seeing an import error for mangum?
Your editor may highlight from mangum import Mangum as unresolved. This is expected because mangum is not installed in your local environment yet. SAM will install it from requirements.txt during the build step.
help me understand why my mangum import shows an error
✔️ Awesome, I've got everything!
Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete task_api/main.py file for comparison.
from fastapi import FastAPI
from mangum import Mangum
from .routes import router
app = FastAPI(
title="Task Management API",
description="A .NET-style task API migrated to Python with FastAPI",
version="1.0.0",
)
app.include_router(router)
@app.get("/")
def root():
return {"message": "Task Management API is running", "version": "1.0.0"}
handler = Mangum(app, lifespan="off")
Your API code is complete. Next up, you will define the infrastructure that deploys this code to AWS and test it locally before going live.
Define the Infrastructure with SAM
Your API code is complete. You have models, routes, and the main application all wired together into a working FastAPI app.
Now you need to tell AWS how to run this code. AWS SAM (Serverless Application Model) lets you define your entire cloud infrastructure in a single YAML file. Instead of clicking through the AWS console to create a Lambda function and API Gateway, you describe what you want and SAM builds it for you.
In this step, get ready to:
- Write a SAM template that defines your Lambda function and API Gateway.
- Add pinned dependencies so SAM can bundle them with your function.
- Build and test the full API locally before deploying to AWS.
Write the SAM template
In .NET, you might use a Startup.cs or Program.cs to configure hosting. SAM replaces all of that with a declarative YAML template that defines your function, its runtime, and how requests reach it.
- Open template.yaml in your project root (the same folder that contains the task_api/ directory) and paste the following:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Task Management API - .NET to Python migration
Globals:
Function:
Timeout: 30
MemorySize: 256
Resources:
TaskApiFunction:
Type: AWS::Serverless::Function
Properties:
Handler: task_api.main.handler
Runtime: python3.12
CodeUri: .
Architectures:
- x86_64
Events:
RootPath:
Type: HttpApi
Properties:
Path: /
Method: ANY
ProxyPath:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
Outputs:
TaskApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
TaskApiFunction:
Description: Lambda Function ARN
Value: !GetAtt TaskApiFunction.Arn
What does this template do?
This template has four sections that work together to define your serverless API:
- Transform: AWS::Serverless-2016-10-31 tells CloudFormation to interpret this as a SAM template with simplified serverless syntax.
- Globals sets a 30-second timeout and 256 MB of memory for all functions in this template.
- Handler: task_api.main.handler points to the Mangum handler you created in main.py. This is how Lambda knows where to send incoming requests.
- Events defines two HttpApi routes: a root path / and a catch-all /{proxy+}. Together, they forward every HTTP request to your FastAPI app's internal router.
Why two routes instead of one?
API Gateway's /{proxy+} pattern catches all sub-paths like /tasks and /tasks/abc123. However, it does not match the bare root path /. You need a separate RootPath event to handle requests to your API's base URL.
- Save template.yaml.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) for indentation. Each nested level should be indented by exactly 2 spaces.
If your editor auto-converts spaces to tabs, check its settings. Most editors have a "Spaces: 2" option in the bottom status bar.
Help me fix a YAML indentation error in my SAM template
✔️ Awesome, I've got everything!
Great. Double check you've saved template.yaml before moving on.
ⓧ I'd like to double check the full code
Here is the complete template.yaml file. Compare yours against it:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Task Management API - .NET to Python migration
Globals:
Function:
Timeout: 30
MemorySize: 256
Resources:
TaskApiFunction:
Type: AWS::Serverless::Function
Properties:
Handler: task_api.main.handler
Runtime: python3.12
CodeUri: .
Architectures:
- x86_64
Events:
RootPath:
Type: HttpApi
Properties:
Path: /
Method: ANY
ProxyPath:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: ANY
Outputs:
TaskApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
TaskApiFunction:
Description: Lambda Function ARN
Value: !GetAtt TaskApiFunction.Arn
Add the pinned dependencies to requirements.txt
When SAM builds your Lambda function, it installs the Python packages listed in requirements.txt. Pinning exact versions ensures your deployment is reproducible every time.
- Open task_api/requirements.txt and add the following two dependencies:
fastapi==0.115.12
mangum==0.19.0
What does this file do?
- fastapi==0.115.12 is the web framework that powers your API routes and request handling.
- mangum==0.19.0 is the adapter that translates AWS Lambda events into requests that FastAPI can understand.
These are the only two external packages your function needs. Keeping the dependency list small reduces your deployment package size and speeds up cold starts.
- Save task_api/requirements.txt.
✔️ Awesome, I've got everything!
Perfect. Your infrastructure definition is ready for SAM to build.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt:
fastapi==0.115.12
mangum==0.19.0
Build and test locally with SAM
SAM can simulate the full Lambda and API Gateway environment on your machine. This means you can test every endpoint before deploying to AWS.
- Build your function package by running this command from your project root (the folder containing template.yaml):
sam build
- You should see output ending with Build Succeeded and a new .aws-sam/ directory appears in your project.
Build failed?
- Make sure you are running the command from the same directory that contains template.yaml.
- Check that requirements.txt has no typos in the package names or version numbers.
- Confirm Python is installed and accessible from your terminal by running python --version.
Help me debug a sam build failure for my Python Lambda function
Docker must be running
The next command (sam local start-api) uses Docker to emulate the Lambda environment locally. Make sure Docker Desktop is running before you proceed.
- Start the local API by running:
sam local start-api
- You should see a message like Running on http://127.0.0.1:3000. This means your local API Gateway is ready to accept requests.
Seeing a Docker error?
- If you see "Error: Running AWS SAM projects locally requires Docker", open Docker Desktop and wait for it to fully start before retrying.
- On Windows, make sure Docker Desktop is set to use Linux containers (the default).
Help me fix a Docker-related error when running sam local start-api
Now test your endpoints. Open a second terminal window (keep the SAM local server running in the first one).
- Create a task by running:
curl -X POST http://127.0.0.1:3000/tasks/ -H "Content-Type: application/json" -d "{\"title\": \"Test task\", \"description\": \"My first task\"}"
- You should see a JSON response with your task's id, title, created_at, and other fields.
- Retrieve all tasks by running:
curl http://127.0.0.1:3000/tasks/
- You should see a JSON array containing the task you just created.
- Test the root endpoint by running:
curl http://127.0.0.1:3000/
- You should see {"message":"Task Management API is running","version":"1.0.0"}.
What just happened?
SAM spun up a Docker container that mimics the real AWS Lambda environment. Your requests went through a local API Gateway simulation, into the container, and hit your FastAPI app exactly as they will in production.
The first request may have been slow (a few seconds). That is the local cold start as Docker initializes the container. Subsequent requests are much faster.
- When you are done testing, press Ctrl+C in the terminal running SAM to stop the local server.
Getting a 500 Internal Server Error?
- Check the SAM terminal window for a Python traceback. The most common cause is a typo in an import path (e.g. from .models import ... must match your file names exactly).
- Verify the Handler in template.yaml is exactly task_api.main.handler. A wrong handler path will crash on startup.
- If you edited code after running sam build, you need to run sam build again before sam local start-api will reflect those changes.
Help me debug a 500 error from sam local start-api
Your API works locally and your infrastructure is fully defined. Next up, you will deploy this to a live AWS endpoint that anyone can call.
Deploy to AWS Lambda
Your API works locally. Now it needs to work for anyone on the internet.
In this step, you will deploy your FastAPI application to AWS Lambda using a single SAM command. SAM handles everything: creating the Lambda function, setting up API Gateway, provisioning IAM roles, and giving you a live public URL.
In this step, get ready to:
- Deploy your API to AWS with a guided SAM deployment.
- Test all five CRUD endpoints on your live public URL.
Deploy with SAM
The sam deploy --guided command walks you through an interactive setup the first time you deploy. It asks a series of questions about your stack name, region, and permissions, then creates all the AWS resources defined in your template.yaml.
- Make sure you are in your project root directory (the folder containing template.yaml).
- Run the guided deployment by running this command:
sam deploy --guided
SAM will ask you a series of interactive prompts. Enter these values one at a time:
- For Stack Name, enter task-api.
- For AWS Region, press Enter to accept the default (us-east-1).
- For Confirm changes before deploy, enter y.
- For Allow SAM CLI IAM role creation, enter Y.
- For Disable rollback, enter N.
- For TaskApiFunction has no authentication, enter y to confirm this is intentional.
- For Save arguments to configuration file, enter Y.
- For SAM configuration file and SAM configuration environment, press Enter to accept the defaults.
SAM will show you a changeset summarizing the resources it will create. When prompted to Deploy this changeset?, enter y.
What is SAM creating for you?
Behind the scenes, SAM creates a CloudFormation stack with four resources: a Lambda function running your FastAPI code, an HTTP API Gateway that routes internet traffic to it, an IAM execution role granting the function permissions to run, and an S3 bucket storing your deployment package.
This is infrastructure-as-code in action. Your template.yaml defines the entire stack, and SAM translates it into real AWS resources.
The deployment takes 1-3 minutes. When it finishes, you will see an Outputs section in your terminal with a TaskApiUrl value. This is your live public API URL.
- Copy the TaskApiUrl value from the output. It looks like https://<id>.execute-api.us-east-1.amazonaws.com/.
Deployment failed?
If you see a permissions error, make sure your AWS CLI credentials have admin access. Run aws sts get-caller-identity to verify your credentials are active.
If you see a timeout error, check your internet connection and try running sam deploy --guided again. CloudFormation will pick up where it left off.
Help me troubleshoot my SAM deployment error.
Test the live API
Your API is live. Time to prove it works by running through all five CRUD endpoints against the real deployment.
- First, verify the root endpoint is responding by running this command (replace the URL with your own TaskApiUrl):
curl [[TASK_API_URL="your TaskApiUrl from the deployment output"]]
You should see the JSON response: {"message": "Task Management API is running", "version": "1.0.0"}.
- Create a task by sending a POST request:
curl -X POST [[TASK_API_URL="your TaskApiUrl"]]tasks/ -H "Content-Type: application/json" -d "{\"title\": \"Deploy to production\", \"description\": \"Ship the task API to AWS Lambda\"}"
You should see a JSON response with a generated id, your title and description, is_completed set to false, and timestamps.
- Copy the id from the response. You will need it for the next commands.
- Retrieve all tasks by running:
curl [[TASK_API_URL="your TaskApiUrl"]]tasks/
You should see an array containing the task you just created.
- Retrieve a single task by its ID:
curl [[TASK_API_URL="your TaskApiUrl"]]tasks/[[TASK_ID="the task ID from the POST response"]]
- Update the task to mark it as completed:
curl -X PUT [[TASK_API_URL="your TaskApiUrl"]]tasks/[[TASK_ID="the task ID from the POST response"]] -H "Content-Type: application/json" -d "{\"is_completed\": true}"
You should see the task with is_completed now set to true and an updated updated_at timestamp.
- Delete the task:
curl -X DELETE [[TASK_API_URL="your TaskApiUrl"]]tasks/[[TASK_ID="the task ID from the POST response"]]
A successful delete returns no response body (HTTP 204). Confirm the task is gone by running the GET all tasks command again. You should see an empty array.
Why does this feel like your local test?
Your FastAPI code is identical in both environments. The only difference is the entry point. Locally, SAM simulates API Gateway. In production, Mangum translates real Lambda events into ASGI requests that FastAPI understands.
This is the power of the Mangum adapter. You write a standard Python web app and deploy it anywhere without rewriting your route logic.
Getting a 500 Internal Server Error?
Check that you ran sam build before deploying. If you made code changes after building, run sam build && sam deploy to rebuild and redeploy.
If you see {"message":"Not Found"}, make sure your URL ends with a trailing slash for the /tasks/ endpoints.
Help me debug my deployed Lambda API returning errors.
Your API is live and publicly accessible. Anyone with the URL can create, read, update, and delete tasks. Next up, you will measure how Lambda performs and optimize cold start times.
Measure and Optimize Performance
Your API is live and handling requests. But how fast is it actually responding? Lambda functions have a unique performance characteristic called a cold start that can add seconds to the first request after a period of inactivity.
In this step, you will measure your API's cold start latency versus warm invocation speed. Then you will review your deployment package to ensure it stays lean and understand the lazy imports pattern for keeping cold starts fast.
In this step, get ready to:
- Measure cold start and warm invocation response times on your live API.
- Optimize your deployment package and understand the lazy imports pattern for faster cold starts.
Measure cold start time
When a Lambda function has not been invoked for several minutes, AWS shuts down the execution environment. The next request has to spin up a fresh environment, load your code, and initialize all imports before it can respond. This is the cold start.
A warm invocation reuses an existing environment that is already loaded. The difference between the two tells you exactly how much overhead your application's startup adds.
- Wait at least 10 minutes since your last API request. This ensures Lambda has shut down the execution environment.
- Measure the cold start response time by running this command:
Measure-Command { Invoke-WebRequest -Uri "[[API_URL="your API Gateway URL, e.g. https://abc123.execute-api.us-east-1.amazonaws.com/"]]/tasks" } | Select-Object TotalSeconds
What does this command do?
Measure-Command is PowerShell's built-in stopwatch. It runs the web request inside the curly braces and reports how long it took in seconds.
You should see a value between 1 and 3 seconds for the cold start. This includes AWS spinning up the Lambda environment, loading Python, and importing FastAPI and Mangum.
- Immediately run the same command again (within a few seconds) to measure a warm invocation:
Measure-Command { Invoke-WebRequest -Uri "[[API_URL="your API Gateway URL, e.g. https://abc123.execute-api.us-east-1.amazonaws.com/"]]/tasks" } | Select-Object TotalSeconds
The warm invocation should complete in under 100 milliseconds. That is the true speed of your API once Lambda is already running.
Why is there such a big difference?
The cold start penalty comes from three things: AWS provisioning a micro-VM, Python loading the interpreter, and your code importing all its dependencies. Once the environment is warm, none of that needs to happen again.
In .NET, the equivalent problem is the JIT compilation overhead on first request. Python does not have JIT, but the module import tree is the primary bottleneck.
Seeing timeouts or very slow responses?
If the cold start takes longer than 5 seconds, your Lambda might be timing out. Check that the Timeout value in your template.yaml is set to 30 seconds.
If you get connection errors, verify your API URL ends with /tasks and that you are using the correct URL from the sam deploy output. Help me debug my Lambda cold start measurement.
Optimize for faster cold starts
The biggest factor in Python Lambda cold starts is the size and complexity of your import tree. Every dependency you add increases the time Python spends loading modules on that first invocation.
- Review your requirements.txt to confirm you only have the two dependencies your application needs:
fastapi==0.115.12
mangum==0.19.0
Why does this matter?
Many Python projects accumulate heavy dependencies like boto3, SQLAlchemy, or ORM libraries that add 2-3 seconds to cold starts. Your current stack (FastAPI + Mangum) is intentionally lean. Keeping it that way is the single most impactful optimization.
In .NET, the equivalent optimization is trimming unused assemblies from the publish output. In Python, it means keeping requirements.txt tight.
The second optimization technique is lazy imports. Instead of importing everything at the top of your handler file, you defer imports until they are actually needed inside a function call.
- Open task_api/main.py and look at the imports at the top:
from fastapi import FastAPI
from mangum import Mangum
from .routes import router
How would lazy imports work here?
In a larger application, you would move heavy imports (like database drivers or ML libraries) inside the functions that use them. This way, Python only loads those modules when a specific endpoint is called, not on every cold start.
Your current main.py is already minimal. FastAPI and Mangum are both required on every request, so they belong at the top level. The lazy imports pattern becomes valuable when you add heavier dependencies in the future.
- Re-deploy your application to confirm the lean package is live by running these commands:
sam build && sam deploy
What does this command do?
sam build packages your code and installs only the dependencies from requirements.txt into a deployment artifact. sam deploy uploads the artifact and updates your Lambda function. Since you already ran sam deploy --guided before, SAM remembers your configuration and deploys without prompts.
- Wait for the deployment to complete. You should see Successfully created/updated stack - task-api in the output.
- Wait 10 minutes, then measure a fresh cold start to compare against your earlier measurement:
Measure-Command { Invoke-WebRequest -Uri "[[API_URL="your API Gateway URL, e.g. https://abc123.execute-api.us-east-1.amazonaws.com/"]]/tasks" } | Select-Object TotalSeconds
Your cold start time should remain in the 1-3 second range. The key takeaway is that your stack is already optimized by design. FastAPI and Mangum add minimal overhead compared to heavier frameworks.
Deployment failed?
If sam build fails, make sure Docker Desktop is running. SAM uses Docker to install Python dependencies in a Lambda-compatible environment.
If sam deploy fails with a permissions error, confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity. Help me fix my SAM deployment error.
You now have concrete performance data on your deployed API. You know the cold start cost, you understand why it happens, and you have verified that your dependency tree is as lean as it can be. Next up, tackle the Secret Mission to add caching and logging for even sharper performance insights.
Secret mission
Add Response Caching and Logging
Your API works, but how fast is it? In this extension, you will add response caching and request logging to measure and improve your API's performance. These are exactly the kind of optimizations that separate a basic deployment from a production-ready service.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running or delete them entirely. This project uses AWS Free Tier services, so there are no ongoing costs as long as you stay within free tier limits.
Cost warning
Your Lambda function includes 1M free requests per month indefinitely. However, the API Gateway HTTP API free tier (1M calls/month) only lasts for your first 12 months on AWS.
If your AWS account is older than 12 months, the API Gateway may incur small charges. Delete the stack if you are past the free tier window.
Resources you used:
- AWS CloudFormation stack (task-api).
- AWS Lambda function.
- API Gateway HTTP API.
- IAM execution role for Lambda.
- S3 deployment bucket (created by SAM).
- Local project files in the task-api folder on your Desktop.
Keep everything running
No action needed. Choose this if you want to keep your API live as a portfolio piece or continue building on it.
- Your API stays publicly accessible at the URL from your deployment output.
- Under free tier limits (1M requests/month for Lambda, 1M calls/month for API Gateway), this costs nothing.
- You can re-deploy updates at any time by running sam build && sam deploy from your project folder.
Pause - I'll come back to this later
There is no pause option for Lambda. Your function only runs when called, so it costs nothing while idle. If you want to prevent any requests from reaching your API, delete the stack and re-deploy later.
- Keep your local project files intact so you can re-deploy with a single command later.
- If you want to block access to the API without deleting it, you can remove the API Gateway event from your template.yaml and re-deploy.
Delete - I don't want to use this again
Remove all AWS resources created by this project. This deletes the Lambda function, API Gateway, IAM role, and deployment bucket.
- Open your terminal and navigate to your project folder.
- Run this command to delete all AWS resources:
sam delete --stack-name task-api
- Confirm each prompt by entering y when asked.
- Once complete, delete your local project folder if you no longer need the code.
Nice Work!
Nice Work!
You just migrated a .NET-style API to Python and deployed it serverlessly to AWS Lambda. That is the exact workflow enterprise teams need when modernizing legacy systems.
Here's what you accomplished:
- Built a REST API with FastAPI using enterprise Python patterns (routers, Pydantic models, type hints).
- Translated .NET architectural concepts (Controllers, DTOs, Data Annotations) to their Python equivalents.
- Deployed a serverless application to AWS Lambda using SAM for infrastructure-as-code.
- Measured and optimized cold start performance to understand real-world Lambda behavior.
- Secret Mission: Added response caching and request logging to demonstrate performance optimization skills.
Ready to quiz yourself?