Skip to main content

Cold Starts in Lambda: Optimize Python Performance

A cold start occurs when Lambda invokes a function after a period of inactivity, requiring the runtime to initialize the execution environment. Python cold starts typically add 1–3 seconds of latency (depending on dependencies), making them noticeable for user-facing APIs. Understanding and mitigating cold starts is critical for production serverless applications.

What Causes Cold Starts and How Long Do They Take?

When you first invoke a Lambda function or after 15 minutes of inactivity, Lambda provisions a new execution environment: downloads your code, initializes the Python runtime, and executes module-level imports. This initialization adds latency.

Cold start latency breakdown (approximate, for Python 3.12 with minimal dependencies):

  • Download code: 100–300 ms
  • Initialize runtime: 200–500 ms
  • Import modules: 500–2000 ms (depends on dependency count/size)
  • Total: 1–3 seconds

Warm starts (reusing an existing environment) add only handler execution time: 10–100 ms.

Cold start frequency depends on traffic:

  • Steady traffic: Environments stay warm; cold starts rare.
  • Bursty traffic: Multiple concurrent invocations scale horizontally; each new invocation may have a cold start.
  • Low traffic / idle periods: After 15 minutes, environments are reclaimed; next invocation is cold.

Minimize Dependencies and Code Size

The largest contributor to cold start latency is Python module imports. Reduce it:

Remove unused packages:

# Before: Function ZIP with many unused dependencies
pip install requests pandas numpy scikit-learn -t lambda-package/python/
cd lambda-package && zip -r function.zip . && cd ..
du -h lambda-package.zip
# 150 MB (includes all dependencies)

# After: Remove unused packages
pip install requests -t lambda-package/python/ # Only required dependency
cd lambda-package && zip -r function.zip . && cd ..
du -h lambda-package.zip
# 5 MB

Lazy-import heavy modules:

Instead of importing at module level (slowing every invocation), import inside the handler only when needed:

import json

# BAD: Imported for every invocation, even if not used
import pandas as pd
import numpy as np

def lambda_handler(event, context):
if event.get('action') == 'simple':
return {'result': 'quick'}
# pandas/numpy never used for this action
return {'result': 'fast'}

Better approach—lazy imports:

import json

def lambda_handler(event, context):
if event.get('action') == 'analyze':
# Import only when needed
import pandas as pd
import numpy as np

df = pd.DataFrame(...)
return {'result': df.describe().to_dict()}

return {'result': 'quick'}

Benchmark the difference:

  • Eager imports: cold start 2.5 seconds
  • Lazy imports: cold start 0.8 seconds (for actions not using pandas)

Use lightweight alternatives:

  • Replace requests with boto3's built-in HTTP (boto3 is usually pre-installed)
  • Replace pandas with csv or json for simple data
  • Replace scikit-learn with scipy or hand-coded logic for inference

Use Lambda Layers to Separate Code and Dependencies

Store dependencies in a Lambda Layer (immutable, shared across invocations) to reduce function ZIP size and accelerate deployments:

# Create layer with dependencies
mkdir -p layer/python
pip install requests -t layer/python/

# Function ZIP contains only code (5 KB vs. 50 MB)
zip -r function.zip app.py

# Deploy layer once, then attach to multiple functions
aws lambda publish-layer-version \
--layer-name my-dependencies \
--zip-file fileb://layer.zip \
--compatible-runtimes python3.12

This splits cold start penalty: dependencies are cached in the layer, reducing initialization time.

Use Provisioned Concurrency for User-Facing APIs

Provisioned Concurrency pre-warms Lambda execution environments, eliminating cold starts for concurrent requests. Configure it:

  1. In Lambda Console, select your function
  2. Go to ConcurrencyProvisioned concurrency configuration
  3. Set number of provisioned environments (e.g., 5 for 5 concurrent requests without cold starts)
  4. Click Save

AWS keeps those environments warm 24/7, incurring additional cost (roughly $1–2 per environment per month), but eliminates cold-start latency.

For an API handling peak traffic of 10 concurrent users:

import json
import time

start = time.time()

def lambda_handler(event, context):
# With provisioned concurrency, this runs with <50 ms cold start overhead
duration = time.time() - start

return {
'statusCode': 200,
'body': json.dumps({'duration_ms': int(duration * 1000)})
}

Cost calculation: 5 provisioned environments × $1.60/month = $8/month (typical pricing). If Provisioned Concurrency is not feasible budget-wise, evaluate:

  • API Gateway caching to reduce invocation frequency
  • CloudFront caching for static responses
  • Delayed/asynchronous processing via SQS instead of synchronous Lambda

Optimize Import Order and Module Structure

Place fast imports at the top, heavy imports lazily:

# Good import ordering
import json # stdlib, fast
import os
import sys
from datetime import datetime

# External packages that are always needed
import boto3 # AWS SDK, pre-installed on Lambda

# Lazy imports (commented out, imported in handler if needed)
# import requests
# import pandas as pd

def lambda_handler(event, context):
# boto3 is ready; requests/pandas lazy-loaded on demand
...

Use local imports for handler-specific modules:

my-function/
app.py # Main handler
utils/
database.py # Database utilities
cache.py # Caching logic
handlers/
auth.py # Authentication handler

In app.py:

def lambda_handler(event, context):
if event.get('action') == 'auth':
from handlers.auth import authenticate
return authenticate(event)

if event.get('action') == 'query':
from utils.database import query_db
return query_db(event)

return {'statusCode': 400, 'error': 'Unknown action'}

This loads only the code needed for the current request.

Monitor Cold Starts with CloudWatch

Log cold-start indicators:

import json
import os
import time

start_time = time.time()

def lambda_handler(event, context):
is_cold = os.environ.get('_X_AMZN_TRACE_ID') is None
handler_start = time.time()

# Your logic here
result = {'message': 'Hello'}

handler_duration = (time.time() - handler_start) * 1000
total_duration = (time.time() - start_time) * 1000
cold_start_duration = total_duration - handler_duration if is_cold else 0

print(json.dumps({
'cold_start': is_cold,
'total_duration_ms': int(total_duration),
'handler_duration_ms': int(handler_duration),
'cold_start_overhead_ms': int(cold_start_duration)
}))

return {'statusCode': 200, 'body': json.dumps(result)}

In CloudWatch Logs, filter for "cold_start: true" to analyze cold-start frequency and duration.

Connection Pooling and Global State

Reuse connections and expensive objects across invocations by storing them at module level:

import json
import boto3
import os

# Module-level initialization (runs once per execution environment)
s3_client = boto3.client('s3')
dynamodb = boto3.resource('dynamodb', region_name=os.environ['AWS_REGION'])

def lambda_handler(event, context):
# Reuse s3_client and dynamodb—no re-initialization
bucket = os.environ['BUCKET_NAME']
table_name = os.environ['TABLE_NAME']

# Use cached clients
table = dynamodb.Table(table_name)
items = table.scan()

return {'statusCode': 200, 'count': items['Count']}

This avoids creating new clients on every invocation, reducing latency.

Comparison of Cold Start Mitigation Strategies

StrategyCold Start ReductionCostImplementation Effort
Remove unused dependencies50–70%$0Low
Lazy imports20–40%$0Low
Lambda Layers10–20%$0Medium
Provisioned Concurrency100%High ($1–2/month per environment)Low
Connection pooling5–10%$0Low
Code optimization10–30%$0Medium

Best practice: Combine lightweight strategies (remove dependencies, lazy imports, connection pooling) for most use cases. Use Provisioned Concurrency for latency-sensitive APIs.

Key Takeaways

  • Cold starts add 1–3 seconds of latency for Python functions; warm starts add <100 ms.
  • Minimize dependencies, use lazy imports, and employ Lambda Layers to reduce cold-start overhead.
  • Provisioned Concurrency eliminates cold starts but incurs additional cost; evaluate tradeoff for user-facing APIs.
  • Module-level connections and global state are reused across invocations, reducing initialization overhead.
  • Monitor cold-start frequency with CloudWatch Logs to identify trends and optimization opportunities.

Frequently Asked Questions

How can I tell if a cold start occurred?

Check CloudWatch Logs for initialization duration. AWS also provides InitDuration in Lambda Insights. Alternatively, log the time between environment startup and handler execution. For reliable detection, use AWS Lambda PowerTuning: https://github.com/alexcasalboni/aws-lambda-power-tuning.

Does memory allocation affect cold start time?

Yes, slightly. Higher memory (e.g., 1024 MB) allocates more CPU, making Python initialization slightly faster. However, the difference is small (<10%). Optimize code/dependencies first; adjust memory if needed.

Can I pre-warm Lambda to avoid cold starts?

Partially. CloudWatch Events can trigger dummy invocations periodically (e.g., every 5 minutes) to keep environments warm. However, this is costly and doesn't scale with traffic. Provisioned Concurrency is the proper solution.

Does using Python 3.12 vs. 3.11 affect cold starts?

Marginally. Python 3.12 is slightly faster, but differences are <5%. Focus on dependencies and code size; language version matters less.

What's the cost difference between Provisioned Concurrency and on-demand Lambda?

On-demand: ~$0.0000167 per invocation + duration. Provisioned Concurrency: ~$1.60 per environment per month + duration. For low-traffic APIs, on-demand is cheaper; for high-traffic or latency-sensitive APIs, provisioned concurrency pays off.

Further Reading