AWS Lambda Functions

By Vishvesh Patel · DevOps EngineerUpdated September 15, 2026

Writing, packaging and deploying Lambda functions. Handler signatures, deployment packages versus container images, layers, versions and aliases, and local testing.

A Lambda function is a handler, a runtime, and a set of configuration. This page is about writing and shipping one; the execution model, concurrency and cost behaviour are covered in AWS Lambda.

The handler

import json
import os
import boto3

# Module scope. Runs ONCE per execution environment, reused across invocations
s3 = boto3.client("s3")
BUCKET = os.environ["BUCKET_NAME"]

def handler(event, context):
    # Runs on every invocation
    key = event["Records"][0]["s3"]["object"]["key"]
    obj = s3.get_object(Bucket=BUCKET, Key=key)

    return {
        "statusCode": 200,
        "body": json.dumps({"size": obj["ContentLength"]}),
    }

Where you put initialisation matters more than anything else in this file. Code at module scope runs once when the execution environment is created and is reused across invocations; code inside the handler runs every time. Creating the boto3 client inside the handler adds its setup cost to every request.

The handler takes two arguments:

  • event: the input, whose shape depends entirely on the trigger. An S3 event, an API Gateway request and an SQS batch look nothing like each other.
  • context: runtime information, most usefully context.get_remaining_time_in_millis() for deciding whether there is time to start another unit of work before the timeout.

The handler is configured as file.function, index.handler for index.py, or src/app.lambda_handler for a nested file.

Packaging

Three options, in increasing order of heft.

A zip archive

The default. Your code plus any dependencies not in the runtime:

pip install -r requirements.txt -t package/
cd package && zip -r ../function.zip . && cd ..
zip -g function.zip index.py

aws lambda update-function-code \
  --function-name payments-processor \
  --zip-file fileb://function.zip

Limits: 50 MB zipped for direct upload, 250 MB unzipped including layers. Upload via S3 for anything above 50 MB.

Dependencies with compiled components, anything wrapping C libraries, must be built for Amazon Linux on the target architecture, not for your laptop. Building on macOS and deploying produces an import error at runtime that looks unrelated. Build in a container matching the runtime, or use the AWS SAM build process which handles it.

Layers

A layer is a zip of shared dependencies, mounted at /opt and reusable across functions:

aws lambda publish-layer-version \
  --layer-name common-deps \
  --zip-file fileb://layer.zip \
  --compatible-runtimes python3.12

Up to five layers per function, counting toward the same 250 MB unzipped limit. They keep your function package small and avoid shipping identical dependencies with every function.

The trade-off is a second versioned artifact to manage, and layers are frequently over-used. For a single function they add ceremony with no benefit.

A container image

Up to 10 GB, which is the reason to use them:

FROM public.ecr.aws/lambda/python:3.12
COPY requirements.txt .
RUN pip install -r requirements.txt -t "${LAMBDA_TASK_ROOT}"
COPY index.py "${LAMBDA_TASK_ROOT}"
CMD ["index.handler"]

Worth it for large machine learning dependencies, or when you want one build process for both Lambda and container workloads. Cold starts are comparable to zip for reasonably sized images.

Configuration

aws lambda create-function \
  --function-name payments-processor \
  --runtime python3.12 \
  --architectures arm64 \
  --handler index.handler \
  --role arn:aws:iam::111122223333:role/payments-lambda-role \
  --zip-file fileb://function.zip \
  --memory-size 512 \
  --timeout 30 \
  --environment 'Variables={BUCKET_NAME=acme-artifacts,LOG_LEVEL=info}'

Three choices in there deserve attention.

--architectures arm64 selects Graviton, which is around 20% cheaper and usually faster. Use it unless a dependency requires x86.

--memory-size also sets CPU, which scales proportionally. More memory frequently makes a function both faster and cheaper, because you are billed for memory multiplied by duration. Measure with AWS Lambda Power Tuning rather than assuming the smallest is cheapest.

--timeout defaults to 3 seconds and maxes at 15 minutes. Set it deliberately: too high and a hung function burns money, too low and legitimate work is cut off.

Environment variables and secrets

Environment variables are fine for configuration. For secrets, they are visible in the console and in get-function-configuration, so prefer reading from Secrets Manager or Parameter Store at initialisation:

import boto3
ssm = boto3.client("ssm")
API_KEY = ssm.get_parameter(Name="/prod/payments/api-key", WithDecryption=True)["Parameter"]["Value"]

Module scope means this runs once per environment rather than per invocation. Better still, use the AWS Parameters and Secrets Extension, which caches locally so repeated reads never leave the execution environment.

Versions and aliases

By default you deploy to $LATEST, which is mutable, so an invocation during a deploy can hit either version.

Publish a version to get an immutable numbered snapshot, then point an alias at it:

VERSION=$(aws lambda publish-version --function-name payments-processor --query Version --output text)

aws lambda update-alias \
  --function-name payments-processor \
  --name prod \
  --function-version "$VERSION"

Triggers reference the alias, so deploying is an alias move and rolling back is moving it back. Instant, with no code upload.

Aliases also support weighted routing for canary deploys:

aws lambda update-alias \
  --function-name payments-processor \
  --name prod \
  --function-version 42 \
  --routing-config 'AdditionalVersionWeights={43=0.1}'

Ten percent of traffic to version 43. Watch the metrics, then shift.

Testing locally

# Invoke deployed
aws lambda invoke --function-name payments-processor \
  --payload '{"key":"test.txt"}' --cli-binary-format raw-in-base64-out out.json

# Tail the logs
aws logs tail /aws/lambda/payments-processor --follow

For local iteration, AWS SAM runs the function in a container matching the runtime:

sam local invoke PaymentsFunction --event events/s3-put.json
sam local start-api

That --cli-binary-format raw-in-base64-out flag is needed on CLI v2 for a plain JSON payload, and its absence produces a confusing decoding error.

Deploying properly

Update a function with the CLI once, to see how it works. After that use SAM, Terraform, CDK or the Serverless Framework: a Lambda in production needs its IAM role, triggers, environment, alias and log group managed together, and doing that by hand does not survive a second function.

Quick reference

TaskCommand
Createaws lambda create-function --function-name <n> --runtime <r> --handler <h> --role <arn> --zip-file fileb://f.zip
Update codeaws lambda update-function-code --function-name <n> --zip-file fileb://f.zip
Update configaws lambda update-function-configuration --function-name <n> --memory-size 512 --timeout 30
Invokeaws lambda invoke --function-name <n> --payload '{}' --cli-binary-format raw-in-base64-out out.json
Tail logsaws logs tail /aws/lambda/<n> --follow
Publish a versionaws lambda publish-version --function-name <n>
Move an aliasaws lambda update-alias --function-name <n> --name prod --function-version <v>
Publish a layeraws lambda publish-layer-version --layer-name <n> --zip-file fileb://layer.zip
Local invokesam local invoke <Function> --event event.json

Key takeaways

  • Initialise at module scope, not inside the handler. It runs once per environment instead of per invocation.
  • Compiled dependencies must be built for Amazon Linux and the target architecture, or they fail at import.
  • 50 MB zipped, 250 MB unzipped, 10 GB as a container image.
  • arm64 is ~20% cheaper and usually faster.
  • Memory sets CPU. More is often faster and cheaper. Measure with Power Tuning.
  • Use versions and aliases so deploys and rollbacks are alias moves, not uploads.
  • Do not put secrets in environment variables: read them from Parameter Store or Secrets Manager at init.
  • --cli-binary-format raw-in-base64-out is required for plain JSON payloads on CLI v2.

Check your understanding

Exercise

Where to create the client

A function creates its boto3 client inside the handler. What is the effect of moving it to module scope?

Exercise

A dependency that will not import

A function packaged on macOS fails in Lambda with an error importing a library that works locally. Why?


Frequently Asked Questions

Where should I initialise clients and connections in a Lambda function?

At module scope, outside the handler. That code runs once when the execution environment is created and is reused for every subsequent invocation on that environment, so SDK client construction, connection pools and configuration loading happen once rather than per request. Since most invocations reuse a warm environment, this is the highest-value optimisation available and takes seconds to apply. Only per-request logic belongs inside the handler.

Why does my Python dependency fail to import in Lambda?

Because it contains compiled components built for the wrong platform. Libraries wrapping native code ship binaries specific to an operating system and CPU architecture, so a package installed on macOS or Windows will not load on the Amazon Linux environment Lambda runs. Build the package inside a container matching the Lambda runtime, use sam build --use-container, or install with pip's platform and architecture options targeting manylinux and the architecture you configured.

What are the Lambda deployment package size limits?

50 MB for a zip uploaded directly, larger if you upload via S3, and 250 MB unzipped including any layers. Container images can be up to 10 GB, which is the usual reason to choose them. Machine learning dependencies in particular exceed the zip limits easily. Layers do not increase the total, since they count toward the same 250 MB unzipped budget; they simply let several functions share the same dependencies.

What are Lambda versions and aliases for?

A version is an immutable numbered snapshot of your code and configuration; an alias is a movable pointer to one. Without them you deploy to $LATEST, which is mutable, so an invocation during a deploy can hit either the old or new code. Publishing a version and pointing an alias at it makes deployment an alias move and rollback the same move in reverse. Instant, with no upload. Aliases also support weighted routing for canary releases.

Should I use Lambda layers?

For shared dependencies across several functions, yes. They keep each deployment package small and avoid shipping identical libraries repeatedly. For a single function they add a second versioned artifact to manage with little benefit, and they are frequently over-used for that reason. Note that layers count toward the same 250 MB unzipped limit as your function code, so they are an organisational convenience rather than a way to fit more in.

How do I test a Lambda function locally?

AWS SAM runs your function in a container matching the Lambda runtime: sam local invoke MyFunction --event events/test.json for a single invocation, or sam local start-api to emulate API Gateway. This catches packaging and dependency problems that only appear in the Lambda environment. For invoking a deployed function, use aws lambda invoke with --cli-binary-format raw-in-base64-out, which CLI v2 requires for a plain JSON payload and whose absence produces a confusing decoding error.

Continue Learning

Explore Related Topics

When it breaks

Related Resources