Skip to content
Codeloom
AWS

AWS Lambda with Python: A Practical Guide

Build serverless functions with AWS Lambda and Python — handlers, events, layers, environment variables, API Gateway integration, and best practices.

·4 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How Lambda functions work and the execution model
  • Writing handlers for API Gateway, S3, and SQS events
  • Layers, environment variables, and packaging dependencies
  • Cold starts, timeouts, and performance optimization

Prerequisites

  • Python basics
  • An AWS account with IAM permissions

AWS Lambda runs your code without provisioning servers. You upload a function, define a trigger, and AWS handles scaling, patching, and availability. You pay only for the compute time you use.

The handler function

Every Lambda function has a handler — a function that receives an event and a context.

def handler(event, context):
    name = event.get("name", "World")
    return {
        "statusCode": 200,
        "body": f"Hello, {name}!"
    }
  • event: the input data (JSON parsed into a dict)
  • context: runtime info (function name, memory, time remaining)

API Gateway integration

When Lambda is triggered by API Gateway, the event contains HTTP request details.

import json

def handler(event, context):
    method = event["httpMethod"]
    path = event["path"]
    body = json.loads(event.get("body") or "{}")
    query = event.get("queryStringParameters") or {}

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({
            "message": f"{method} {path}",
            "query": query,
            "input": body,
        }),
    }

S3 event trigger

Process files uploaded to S3:

import boto3

s3 = boto3.client("s3")

def handler(event, context):
    for record in event["Records"]:
        bucket = record["s3"]["bucket"]["name"]
        key = record["s3"]["object"]["key"]

        response = s3.get_object(Bucket=bucket, Key=key)
        content = response["Body"].read().decode("utf-8")

        print(f"Processing {key} from {bucket}")
        print(f"Content length: {len(content)}")

    return {"processed": len(event["Records"])}

SQS trigger

def handler(event, context):
    failed = []
    for record in event["Records"]:
        try:
            body = json.loads(record["body"])
            process_message(body)
        except Exception as e:
            failed.append(record["messageId"])

    return {
        "batchItemFailures": [
            {"itemIdentifier": mid} for mid in failed
        ]
    }

Environment variables

import os

DB_HOST = os.environ["DB_HOST"]
DB_NAME = os.environ.get("DB_NAME", "mydb")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"

Set them in the Lambda console, SAM template, or CDK.

Packaging dependencies

With a requirements.txt

pip install -r requirements.txt -t ./package
cd package && zip -r ../deployment.zip .
cd .. && zip deployment.zip handler.py
aws lambda update-function-code --function-name my-func --zip-file fileb://deployment.zip

With Lambda layers

Layers let you share dependencies across functions.

mkdir -p layer/python
pip install requests -t layer/python
cd layer && zip -r ../my-layer.zip python
aws lambda publish-layer-version \
    --layer-name my-dependencies \
    --zip-file fileb://my-layer.zip \
    --compatible-runtimes python3.12

Cold starts

The first invocation of a Lambda (or after idle time) has extra latency — the “cold start.” The runtime initializes, your code loads, and dependencies import.

Minimizing cold starts

  1. Keep packages small. Fewer dependencies = faster imports.
  2. Initialize outside the handler. Code at module level runs once per cold start.
  3. Use provisioned concurrency for latency-sensitive functions.
import boto3

# Runs once per cold start — reused across invocations
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("users")

def handler(event, context):
    # This runs every invocation
    user_id = event["userId"]
    response = table.get_item(Key={"id": user_id})
    return response.get("Item")

Context object

def handler(event, context):
    print(f"Function: {context.function_name}")
    print(f"Memory: {context.memory_limit_in_mb} MB")
    print(f"Time remaining: {context.get_remaining_time_in_millis()} ms")
    print(f"Request ID: {context.aws_request_id}")

Error handling

class NotFoundError(Exception):
    pass

def handler(event, context):
    try:
        user_id = event.get("userId")
        if not user_id:
            return {"statusCode": 400, "body": "Missing userId"}

        user = get_user(user_id)
        return {"statusCode": 200, "body": json.dumps(user)}

    except NotFoundError:
        return {"statusCode": 404, "body": "User not found"}
    except Exception as e:
        print(f"Error: {e}")
        return {"statusCode": 500, "body": "Internal error"}

Testing locally

if __name__ == "__main__":
    test_event = {
        "httpMethod": "GET",
        "path": "/users",
        "queryStringParameters": {"id": "123"},
    }
    result = handler(test_event, None)
    print(json.dumps(result, indent=2))

Summary

Lambda functions are stateless, event-driven, and scale automatically. Initialize expensive resources outside the handler, keep packages lean, handle errors gracefully, and use layers to share dependencies. Start with a simple API Gateway trigger and expand to S3, SQS, and EventBridge as your architecture grows.