AWS Lambda + Python is the path of least resistance for serverless backends inside AWS. The free tier (1M requests/month, 400,000 GB-seconds compute) covers most small workloads at zero cost. Here's a complete walkthrough from local development to production deploy in roughly an hour.
Step 1: handler signature
A Lambda function is a Python function with this signature:
def handler(event: dict, context) -> dict:
return {"statusCode": 200, "body": "ok"}
The event shape depends on what triggers the Lambda. API Gateway sends an HTTP-style event; SQS sends a batch of messages; S3 sends an object event. context gives runtime info (remaining time, log group, request ID).
Step 2: project layout
my-lambda/
├── src/
│ ├── handler.py
│ └── requirements.txt
├── template.yaml # SAM CloudFormation template
├── events/
│ └── api_event.json # sample event for local testing
└── samconfig.toml
Step 3: handler.py with Powertools
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.utilities.typing import LambdaContext
logger = Logger()
tracer = Tracer()
app = APIGatewayRestResolver()
@app.get("/hello/<name>")
@tracer.capture_method
def hello(name: str):
logger.info("greeting requested", extra={"name": name})
return {"message": f"Hello, {name}!"}
@logger.inject_lambda_context(correlation_id_path="requestContext.requestId")
@tracer.capture_lambda_handler
def handler(event: dict, context: LambdaContext) -> dict:
return app.resolve(event, context)
Powertools gives you structured JSON logging, distributed tracing (X-Ray), and a clean route decorator. All 3 in one library.
Step 4: SAM template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: python3.12
MemorySize: 256
Timeout: 10
Tracing: Active
Environment:
Variables:
POWERTOOLS_SERVICE_NAME: my-api
LOG_LEVEL: INFO
Resources:
ApiFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: handler.handler
Events:
ApiEvent:
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
Policies:
- DynamoDBReadPolicy:
TableName: !Ref EventsTable
EventsTable:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
Step 5: local testing with SAM CLI
brew install aws-sam-cli
sam build
sam local invoke ApiFunction -e events/api_event.json
sam local start-api # spins up HTTP server on localhost:3000
Sample event in events/api_event.json:
{
"httpMethod": "GET",
"path": "/hello/world",
"headers": {},
"queryStringParameters": null,
"body": null,
"requestContext": {"requestId": "test-123"}
}
Step 6: deploy
sam deploy --guided # first time
sam deploy # subsequent
Outputs the API Gateway URL. Hit it: curl https://abc.execute-api.eu-west-1.amazonaws.com/Prod/hello/world
Step 7: IAM least privilege
SAM has policy templates (DynamoDBReadPolicy, S3ReadPolicy, etc.) that grant only what you need. Avoid AdministratorAccess and avoid inline policies that grant * resources.
Pattern:
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref EventsTable
- S3WritePolicy:
BucketName: my-bucket
- Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: arn:aws:secretsmanager:*:*:secret:my-app/*
Step 8: secrets
Don't put secrets in template.yaml or environment variables of the Lambda definition. Use AWS Secrets Manager or SSM Parameter Store. Fetch at cold start:
from aws_lambda_powertools.utilities import parameters
@functools.lru_cache(maxsize=1)
def get_db_password():
return parameters.get_secret("my-app/db-password")
Step 9: observability
Logs go to CloudWatch automatically. With Powertools' @logger.inject_lambda_context, every log line has the request ID, function name, and any structured fields you add. Search in CloudWatch Logs Insights:
fields @timestamp, message, name
| filter level = "ERROR"
| sort @timestamp desc
| limit 50
For alerts: CloudWatch Alarms on error count, duration p99, throttles.
Step 10: cost
| Tier | Free | Paid |
|---|---|---|
| Requests | 1M / month | 0.20 USD per 1M |
| Compute (GB-seconds) | 400,000 / month | 0.0000166667 USD per GB-second |
| API Gateway requests | 1M / month (first 12 months) | 3.50 USD per 1M after |
A 256 MB Lambda running 100ms per request, called 100,000 times per month: 100k requests + 2,560 GB-seconds. Well within free tier.
Comparison
| Item | AWS Lambda | Cloudflare Workers | Vercel Functions |
|---|---|---|---|
| Free tier requests/month | 1M | 100k/day = 3M/month | 100k/day |
| Cold start (Python) | 200-800ms | 0ms | 50-300ms |
| Max execution time | 15 min | 30 sec paid | 10 sec hobby |
| Memory max | 10 GB | 128 MB | 1 GB |
| Language flexibility | Any | JS/TS only | JS/TS/Python/Go |
| Pricing model | Per request + GB-sec | Per request | Per execution + bandwidth |
| Best for | Long jobs, AWS native | Edge HTTP APIs | Next.js / frontend tied |
FAQ
Why Powertools over plain Lambda?
Structured logging, X-Ray tracing, idempotency, validation, parameter caching — all in one library. Plain Lambda lacks all of these out of the box.
How do I reduce cold starts?
Smaller package size, Python 3.12+ (faster startup than 3.10), provisioned concurrency for hot paths (costs extra), SnapStart for Java/Python (10x faster cold start, paid feature).
Should I use Lambda Layers?
For heavy dependencies (Pandas, PIL, ML libs) that don't change often, yes — keeps function package small. For app deps, just include in the function package.
Container images vs zip?
Container images for >50 MB packages or when you need specific OS libraries. Zip for everything else — faster cold starts and simpler deploys.
Need a Lambda backend built right?
We've shipped Python Lambda services for 8 European startups. SAM, Powertools, X-Ray, IaC included.
Book a discovery call