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 Concurrency → Provisioned 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​