Python Serverless on AWS Lambda · Python on Lambda: Runtime Fundamentals
The Handler, Init Code and Leaking State
Why one customer saw another customer's data, and what belongs at module level in a Python function.
Scenario
Example app: a Python function returns a customer's recommended products. To "cache" work, a developer stores intermediate results in module-level variables, and creates a boto3 client inside the handler for every request.
What you see in production
A customer reports seeing another customer's recommendations. Latency also has a long tail: some requests take 300 ms longer than others, and DynamoDB metrics show far more new connections than expected.
The naive code
import boto3
seen_products = [] # module level: shared by every request in this environment
def handler(event, context):
dynamodb = boto3.resource("dynamodb") # new client and connection every invocation
table = dynamodb.Table("products")
for item in table.query(**query_for(event["customer_id"]))["Items"]:
seen_products.append(item["sku"]) # grows forever, mixes customers
return {"customer": event["customer_id"], "recommended": seen_products[-5:]}
Reproduce it
Two customers' requests land in the same execution environment, one after the other:
seen_products = [] # module level, like the naive code
def naive_handler(event):
seen_products.extend(event["skus"])
return {"customer": event["customer"], "recommended": seen_products[-3:]}
def fixed_handler(event):
recent = list(event["skus"]) # request-scoped
return {"customer": event["customer"], "recommended": recent[-3:]}
requests = [{"customer": "alice", "skus": ["A1", "A2", "A3"]}, {"customer": "bob", "skus": ["B1"]}]
for r in requests:
print("naive:", naive_handler(r))
for r in requests:
print("fixed:", fixed_handler(r))
Output
naive: {'customer': 'alice', 'recommended': ['A1', 'A2', 'A3']}
naive: {'customer': 'bob', 'recommended': ['A2', 'A3', 'B1']}
fixed: {'customer': 'alice', 'recommended': ['A1', 'A2', 'A3']}
fixed: {'customer': 'bob', 'recommended': ['B1']}Root cause
Lambda imports your module once per execution environment and calls the handler many times. Module-level objects therefore live across requests from different users. That is exactly what you want for SDK clients and configuration, and exactly wrong for request data. Creating clients inside the handler repeats expensive setup (credential resolution, TLS connections) on every call.
The fix
Put reusable, request-independent objects at module level; keep everything about the request inside the handler:
import os
import boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"]) # created once per environment
def handler(event, context):
items = table.query(**query_for(event["customer_id"]))["Items"]
recommended = [i["sku"] for i in items][:5] # request-scoped
return {"customer": event["customer_id"], "recommended": recommended}
If you really need a cache across invocations, key it explicitly (for example by product ID, never mixed per user), bound its size (functools.lru_cache(maxsize=...) or a TTL cache), and accept that each environment has its own copy.
Trade-offs
- Module-level clients make the first request in each environment slower (init), and every later one faster.
- Per-environment caches are not shared across environments; for a shared cache use ElastiCache or DynamoDB with a TTL.
- Avoid doing network calls at import time that can fail; a failing import makes every invocation in that environment fail.
Detect it in production
- Code review rule: module-level mutable containers (
[],{}) that are written inside the handler. - Memory growth over the life of an environment (
Max Memory Usedrising per invocation in REPORT lines). Init Durationversus handler duration; many new connections relative to concurrency.
Key points
- The module is imported once per environment; the handler runs per request.
- Clients and configuration at module level; request data inside the handler.
- Caches must be keyed, bounded and treated as per-environment.
The execution-environment model is the same in every runtime; the Node.js course's cold-start lesson explores it further.
Further reading: Define Lambda function handler in Python · Lambda execution environment lifecycle
Practice questions
Why can a module-level list leak data between users in a Python Lambda function?
Answer
Lambda imports the module once per execution environment and reuses it for many invocations, potentially from different users. A module-level list written inside the handler keeps growing and mixes data across requests.
Where should a boto3 client be created, and why?
Answer
At module level, so it is created once per execution environment and reused, avoiding repeated credential resolution and connection setup on every invocation.
What makes a cross-invocation cache safe?
Answer
It stores only request-independent data, is keyed explicitly, has a bounded size or TTL, and the code accepts that each environment has its own copy that may be stale.
Open in the app to mark lessons complete, take notes and track your progress.