Here’s my confession. For about eight months in 2023, I managed an EC2 instance running a Flask API that handled roughly 200 requests per day. Two hundred. Not two hundred thousand. Not two hundred per second. Two hundred per day. I was paying around $35 a month for a t3.medium, patching Ubuntu every few weeks, restarting nginx when it decided to act up at 2 AM, and feeling very important about my “infrastructure.” That was dumb. Spectacularly, embarrassingly dumb.
When I finally moved that same API to AWS Lambda, my monthly bill dropped to $0.04. Four paisa worth of compute. Less than a cup of chai from the tapri downstairs. And I never had to SSH into anything again.
So yeah, I’ve got opinions about serverless. Strong ones. And if you’re running a low-to-medium traffic Python API on a server you’re babysitting, I’m probably going to annoy you with what follows. But someone needs to say it: most of us are over-engineering things that don’t need engineering at all.
When Serverless Makes Sense (and When It Doesn’t)
Before we write a single line of Python, let me draw a line in the sand. AWS Lambda and serverless architecture are brilliant for a specific set of problems. APIs that handle bursty traffic. Webhook receivers. Cron jobs that run twice a day. Event-driven processing where something happens in S3 or DynamoDB and you need code to react. Image resizing, email sending, data transformation pipelines. For all of these, serverless is probably the cheapest, most maintainable approach you’ll find in cloud computing today.
But — and I can’t stress this enough — Lambda is not the answer for everything. Long-running processes that exceed 15 minutes? Bad fit. Workloads with consistent, high-throughput traffic where you’d be paying for millions of invocations per hour? Containers might win on cost. WebSocket connections that need to stay alive? Possible with API Gateway WebSocket APIs, but awkward. Machine learning inference with large models? You’ll hit memory limits fast.
Alright. With that out of the way, let’s build something.
Your First Lambda Function
A Lambda function in Python is surprisingly simple. You write a handler — a regular Python function that takes two arguments. event gives you the input data (what triggered the function), and context gives you runtime information like how much time you have left before the function times out. That’s it. No frameworks, no app server, no routing libraries.
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
"""Basic Lambda handler that processes API Gateway events."""
logger.info(f"Received event: {json.dumps(event)}")
# Extract path and method from API Gateway event
http_method = event.get("httpMethod", "GET")
path = event.get("path", "/")
query_params = event.get("queryStringParameters") or {}
body = json.loads(event.get("body") or "{}")
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
},
"body": json.dumps({
"message": "Hello from Lambda!",
"method": http_method,
"path": path,
"query": query_params
})
}
Notice the return format. When you’re integrating with API Gateway, Lambda expects you to return a dictionary with statusCode, headers, and body. And here’s a gotcha that trips up almost everyone the first time: body has to be a JSON string, not a Python dictionary. Miss that detail and you’ll spend twenty minutes staring at a 502 error wondering what went wrong. Ask me how I know.
One thing I genuinely like about this model: there’s no boilerplate. Compare that handler to the equivalent in Flask or FastAPI — you’d need to import the framework, create an app object, define routes with decorators, set up CORS middleware, configure a WSGI/ASGI server. With Lambda, your function is your entire application for that endpoint. Clean. Minimal. Exactly the kind of constraint that forces good design.
Building a CRUD API with DynamoDB
For a real serverless application, you want a serverless database to match. DynamoDB is the natural choice here — it scales without you thinking about it, has single-digit millisecond latency, and you pay per request (no idle costs). Back in late 2024, I migrated a small task manager from PostgreSQL on RDS to DynamoDB, and my database bill went from $15/month to about $0.25. For a side project doing maybe 50 writes and 300 reads a day, that’s the difference between “I should shut this down” and “I’ll keep it running forever.”
Here’s the full CRUD API. I’m going to show all four operations at once because they share the same patterns, and seeing them together makes the event-driven structure click faster.
import json
import uuid
import time
import boto3
from decimal import Decimal
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Tasks")
class DecimalEncoder(json.JSONEncoder):
"""Handle Decimal types returned by DynamoDB."""
def default(self, obj):
if isinstance(obj, Decimal):
return int(obj) if obj % 1 == 0 else float(obj)
return super().default(obj)
def response(status_code, body):
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
"body": json.dumps(body, cls=DecimalEncoder),
}
def create_task(event, context):
"""POST /tasks - Create a new task."""
body = json.loads(event["body"])
if not body.get("title"):
return response(400, {"error": "title is required"})
task = {
"id": str(uuid.uuid4()),
"title": body["title"],
"description": body.get("description", ""),
"status": "pending",
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
table.put_item(Item=task)
return response(201, task)
def get_task(event, context):
"""GET /tasks/{id} - Get a single task."""
task_id = event["pathParameters"]["id"]
result = table.get_item(Key={"id": task_id})
task = result.get("Item")
if not task:
return response(404, {"error": "Task not found"})
return response(200, task)
def list_tasks(event, context):
"""GET /tasks - List all tasks with optional status filter."""
query_params = event.get("queryStringParameters") or {}
status_filter = query_params.get("status")
if status_filter:
result = table.scan(
FilterExpression="task_status = :s",
ExpressionAttributeValues={":s": status_filter},
)
else:
result = table.scan()
return response(200, {"tasks": result["Items"], "count": result["Count"]})
def update_task(event, context):
"""PUT /tasks/{id} - Update a task."""
task_id = event["pathParameters"]["id"]
body = json.loads(event["body"])
update_expr = "SET updated_at = :now"
expr_values = {":now": int(time.time())}
if "title" in body:
update_expr += ", title = :title"
expr_values[":title"] = body["title"]
if "description" in body:
update_expr += ", description = :desc"
expr_values[":desc"] = body["description"]
if "status" in body:
update_expr += ", task_status = :status"
expr_values[":status"] = body["status"]
result = table.update_item(
Key={"id": task_id},
UpdateExpression=update_expr,
ExpressionAttributeValues=expr_values,
ReturnValues="ALL_NEW",
)
return response(200, result["Attributes"])
def delete_task(event, context):
"""DELETE /tasks/{id} - Delete a task."""
task_id = event["pathParameters"]["id"]
table.delete_item(Key={"id": task_id})
return response(204, {})
A few things worth pointing out. See that DecimalEncoder class? DynamoDB returns numbers as Python Decimal types, and json.dumps doesn’t know what to do with them. You’ll get a TypeError at runtime if you forget this. It’s one of those little papercuts that isn’t documented clearly enough anywhere.
Also notice how dynamodb and table are initialized outside any function. That’s intentional and important. More on that in the production section below.
Each handler corresponds to one HTTP method and one route. In a traditional web framework, you’d wire up all routes in a single file. With Lambda, each function is its own isolated unit. Your create function can’t accidentally affect your list function. Failures are isolated. Scaling is independent. If your create endpoint suddenly gets hammered, Lambda spins up more instances of just that function while your read endpoints continue running unbothered.
I think this isolation is the most underappreciated benefit of event-driven architecture. It changes how you reason about failure modes entirely.
The Real Cost Comparison
Alright, let’s talk money. Because honestly, for most side projects and small-to-medium apps built by Indian developers, cost is the deciding factor. I ran a small experiment in early 2025 comparing three deployment options for the exact same Python API handling the same workload: ~10,000 requests per day, average execution time 200ms, 256 MB memory.
| Option | Monthly Cost (ap-south-1) | Ops Overhead |
|---|---|---|
| EC2 t3.small (always on) | ~$15.50 + EBS | Patching, monitoring, restarts |
| ECS Fargate (1 task, 0.25 vCPU) | ~$9.20 | Container builds, health checks |
| Lambda (10K req/day, 200ms avg) | ~$0.62 | Almost nothing |
Read that again. Sixty-two paisa per month for Lambda in the Mumbai region. Even after you add API Gateway costs (~$1.05 for 300K requests/month) and DynamoDB on-demand reads/writes (~$0.30), you’re looking at under $2 total. Versus $15+ for EC2 where you’re also doing sysadmin work for free on weekends.
Now, I should probably mention where this breaks down. A friend of mine at a fintech company in Bangalore tried going all-in on Lambda for a payment processing service doing 50,000+ requests per minute with strict latency requirements. Cold starts were killing their P99 latency, and provisioned concurrency (which eliminates cold starts) ended up costing more than Fargate. They moved back within three months. So the cost math isn’t universally in Lambda’s favor — it depends heavily on your traffic patterns.
Deploying with SAM
Writing Lambda functions locally is great, but you need a way to get them into AWS without clicking through the console like it’s 2015. AWS Serverless Application Model, or SAM, lets you define your entire serverless stack in a YAML template. Functions, API routes, database tables, permissions — all in one file. Version-controlled, reproducible, reviewable in pull requests.
Here’s the SAM template for our task API. Save it as template.yaml in your project root.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Globals:
Function:
Runtime: python3.12
Timeout: 30
MemorySize: 256
Environment:
Variables:
TABLE_NAME: !Ref TasksTable
Resources:
CreateTaskFunction:
Type: AWS::Serverless::Function
Properties:
Handler: app.create_task
CodeUri: src/
Events:
Api:
Type: Api
Properties:
Path: /tasks
Method: POST
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref TasksTable
ListTasksFunction:
Type: AWS::Serverless::Function
Properties:
Handler: app.list_tasks
CodeUri: src/
Events:
Api:
Type: Api
Properties:
Path: /tasks
Method: GET
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TasksTable
GetTaskFunction:
Type: AWS::Serverless::Function
Properties:
Handler: app.get_task
CodeUri: src/
Events:
Api:
Type: Api
Properties:
Path: /tasks/{id}
Method: GET
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TasksTable
TasksTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: Tasks
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
Few things I want to call out. Each function gets only the permissions it actually needs — CreateTaskFunction gets full CRUD policy while ListTasksFunction and GetTaskFunction only get read access. Least privilege isn’t just a security best practice here; it’s dead simple to implement with SAM policies. No excuse to skip it.
Also notice BillingMode: PAY_PER_REQUEST on the DynamoDB table. For variable workloads (which most side projects and early-stage apps have), on-demand pricing beats provisioned capacity every time. You’re not guessing at read/write capacity units or setting up auto-scaling. You just pay for what you use.
Local Testing and Deployment
SAM’s local testing capabilities saved me more debugging hours than I can count. You can invoke individual functions with test events, or spin up an entire local API Gateway that mimics what you’d get in AWS. Changes show up instantly — no deploy-wait-test cycle.
# Install SAM CLI
pip install aws-sam-cli
# Invoke a function locally with a test event
sam local invoke CreateTaskFunction -e events/create.json
# Start a local API Gateway
sam local start-api
# Now test with: curl http://localhost:3000/tasks
# Build and deploy
sam build
sam deploy --guided
# After first deploy, subsequent deploys are simpler:
sam build && sam deploy
sam local uses Docker under the hood to simulate the Lambda runtime environment. Make sure Docker Desktop is running before you try these commands, or you’ll get a cryptic error about “Could not find the Docker daemon.” On a Mac with Apple Silicon, use the --container-host flag if you’re running into architecture mismatch issues.
When you run sam deploy --guided for the first time, it walks you through a series of prompts: stack name, region, whether to allow SAM to create IAM roles, and so on. Your answers get saved in a samconfig.toml file, so subsequent deploys just need sam build && sam deploy. The whole process from local code to live API takes maybe three minutes on a decent internet connection. Try doing that with EC2 provisioning.
Production Hardening
Here’s where I get opinionated again. I’ve seen too many Lambda functions deployed to production that look like someone’s first “Hello World.” No error handling. No structured logging. Client initialization inside the handler (which means you’re paying for SDK setup on every single invocation). Sloppy stuff that works fine in a demo but falls apart at 3 AM when real users are involved.
Let me show you patterns that actually matter in production.
import os
import json
import logging
from functools import wraps
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
# Initialize clients OUTSIDE the handler (reused across invocations)
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def handle_errors(func):
"""Decorator for consistent error handling across handlers."""
@wraps(func)
def wrapper(event, context):
try:
return func(event, context)
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON in request body"})
except KeyError as e:
return response(400, {"error": f"Missing required field: {e}"})
except Exception as e:
logger.exception("Unhandled exception")
return response(500, {"error": "Internal server error"})
return wrapper
@handle_errors
def create_task(event, context):
body = json.loads(event["body"])
# ... implementation
That handle_errors decorator is maybe 15 lines of code and it’ll save your sanity. Without it, an unhandled exception in your Lambda function returns a raw 502 to the client and your only debugging tool is digging through CloudWatch logs. With it, you get proper HTTP status codes, structured error messages, and the logger.exception call captures the full stack trace automatically.
And I’ll say it again because it’s that important: initialize your boto3 clients and DynamoDB table references outside the handler function. When Lambda reuses an execution environment (which it does for “warm” invocations), everything outside the handler persists. Your SDK client, the TCP connection to DynamoDB, the SSL handshake — all reused. Inside the handler, you’d recreate all of that on every invocation. I benchmarked this once and the difference was roughly 80ms per call. At scale, that adds up in both latency and cost.
Cold Starts: The Elephant in the Room
Every serverless conversation eventually lands here, and for good reason. Cold starts happen when Lambda needs to create a new execution environment for your function — downloading your code, starting the runtime, running your initialization code. For Python, you’re looking at roughly 200-500ms added latency on a cold start with a small deployment package. Heavier dependencies push that higher. I’ve seen functions with numpy and pandas take 2-3 seconds on cold start.
Here’s the thing though: cold starts only matter for synchronous, user-facing requests. If your Lambda is processing SQS messages or S3 events, nobody cares if the first invocation takes an extra 300ms. And for APIs with steady traffic, Lambda keeps execution environments warm. In my experience, once you’re getting 5-10 requests per minute to a specific function, cold starts become rare enough that your P50 latency looks fine. It’s the P99 that takes the hit.
Strategies that actually help with cold starts:
- Keep your deployment package small. Don’t bundle your entire
requirements.txtif you only need boto3 and json. Lambda already includes boto3 in the runtime — no need to package it separately. - Use Lambda Layers for large dependencies. Layers are cached separately from your function code, so updates to your business logic don’t trigger a fresh download of your 50 MB machine learning library.
- Provisioned concurrency keeps N execution environments warm at all times. Costs money, but eliminates cold starts entirely. Worth it for latency-critical production APIs. Not worth it for your side project.
- Avoid heavy imports at the module level. If you only need pandas in one branch of your handler, import it inside that branch.
Honestly, cold starts get way more attention than they deserve for most workloads. I’ve shipped probably a dozen Lambda-backed APIs over the past two years, and cold starts were a real problem in exactly one of them. For the rest, nobody noticed.
Beyond CRUD: Where Lambda Gets Interesting
CRUD APIs are the “hello world” of serverless, but they’re just the start. Where Lambda really shines is in event-driven architectures where different pieces of your system communicate through events rather than direct HTTP calls.
Picture this: a user uploads a profile photo to S3. That triggers a Lambda function that resizes the image into three sizes (thumbnail, medium, large). Each resized image lands back in S3, which triggers another Lambda function that updates the user’s record in DynamoDB with the new image URLs. Meanwhile, a third function sends the user a notification that their photo was processed. No orchestration server. No message queue you need to manage. Each function does one thing, triggers on one event, and scales independently.
Step Functions take this further by letting you build state machines that orchestrate multiple Lambda functions with branching, parallel execution, retries, and error handling. I used Step Functions in mid-2025 for an invoice processing pipeline — PDF arrives in S3, gets parsed by one Lambda, validated by another, stored in DynamoDB by a third, and an email sent by a fourth. If any step fails, the state machine retries it or routes to an error-handling branch. The whole thing cost about $3/month for ~500 invoices.
EventBridge is another tool worth knowing about. It’s a serverless event bus that can route events between AWS services, your own applications, and even third-party SaaS tools. You define rules that match event patterns, and EventBridge routes matching events to your Lambda functions. It’s like having a message broker without managing a message broker.
What Would You Move First?
Look, I’m not going to wrap this up with some neat summary telling you “serverless is the future” or whatever. You’re smart enough to decide that for yourself based on your own workload, traffic, and budget. What I will say is that I wasted months — actual months of my life — managing infrastructure that didn’t need managing. Updating packages on a server that handled fewer requests per day than my mom’s WhatsApp group generates. Setting up monitoring for a system that could’ve run on a Raspberry Pi. All because I assumed “real” applications needed “real” servers.
They don’t. Not always. Maybe not even most of the time.
So here’s my question for you: what’s the smallest thing you could move to Lambda today? Not your entire architecture. Not a rewrite. Just one function, one webhook receiver, one cron job that’s running on a server somewhere costing you money and attention it doesn’t deserve. Start there. See what it feels like to deploy code without thinking about servers. You might find, like I did, that going back feels like volunteering for chores you’d already outgrown.