AWS Lambda Layers and Custom Runtimes in 2026
I’ve deployed hundreds of Lambda functions across dozens of AWS accounts. The most common pain I see — even from experienced teams — is the dependency packaging problem. Someone adds Pillow to a Python Lambda, hits the 50MB upload limit, starts inlining libraries manually, and ends up with a zip file that takes three minutes to package and breaks every time a coworker switches laptop architecture.
Lambda layers solve this. Custom runtimes extend it further. Container images solve the hardest cases. Knowing which tool to reach for — and when — is what separates clean Lambda deployments from a maintenance nightmare.
Here’s what actually works in 2026.
What Lambda Layers Actually Solve
The deployment package limit for a Lambda function is 50MB zipped, 250MB unzipped. That sounds like a lot until you try to include numpy, scipy, and Pillow in a single Python function. Those three libraries alone unzip to over 150MB.
Layers let you extract shared dependencies into a separate artifact that’s attached to your function at runtime. The layer lives in /opt/ inside the Lambda execution environment. You keep your function code small — often just a few KB — while the heavy libraries live in the layer.
The other half of the value is sharing. One layer can be attached to up to five functions. If you have fifteen Lambda functions that all process images, they all reference the same Pillow layer instead of bundling Pillow fifteen times. When you need to update the library, you publish a new layer version and update the reference. One place, not fifteen.
There’s also a developer experience angle. Your deployment pipeline uploads the function code in seconds instead of minutes. Local testing becomes faster because you’re not rebuilding a fat zip on every change. The layer changes rarely; the function code changes often. Separating them matches the natural change frequency of the two artifacts.
Layer Structure: The Zip File Matters
Lambda layers follow a specific directory structure. If you get this wrong, your layer imports will fail at runtime with a cryptic No module named 'yourpackage' error.
For Python:
python/
lib/
python3.12/
site-packages/
pillow/
numpy/
...
The python/lib/pythonX.Y/site-packages/ path is what Lambda automatically adds to sys.path. If you zip the packages at the top level without this structure, Lambda won’t find them.
For Node.js:
nodejs/
node_modules/
lodash/
moment/
...
Lambda adds nodejs/node_modules/ to NODE_PATH. The same rule applies — wrong structure means missing imports.
For binaries and shared libraries (relevant for custom runtimes later):
bin/
my-binary
lib/
libsomething.so
Lambda adds /opt/bin/ to PATH and /opt/lib/ to LD_LIBRARY_PATH. When you’re shipping compiled binaries in a layer, this is where they go.
Creating a Python Layer: Pillow and Numpy
Here’s the full workflow for packaging a Python dependencies layer. I do this for every image-processing Lambda — it’s the same setup described in our guide on AWS Lambda Pillow for Complex Image Processing.
The critical detail: you must build the layer on a Linux x86_64 or arm64 environment that matches your Lambda’s target architecture. Pillow and numpy have native extensions. A wheel compiled on macOS will not work in Lambda.
Use Docker to build cleanly:
mkdir -p python/lib/python3.12/site-packages
docker run --rm \
-v "$PWD":/var/task \
public.ecr.aws/lambda/python:3.12 \
pip install pillow numpy \
-t /var/task/python/lib/python3.12/site-packages \
--platform linux/x86_64 \
--only-binary=:all:
zip -r image-processing-layer.zip python/
Then publish the layer:
aws lambda publish-layer-version \
--layer-name image-processing \
--description "Pillow + numpy for image processing" \
--compatible-runtimes python3.12 \
--compatible-architectures x86_64 \
--zip-file fileb://image-processing-layer.zip \
--region us-east-1
This returns a LayerVersionArn that looks like:
arn:aws:lambda:us-east-1:123456789012:layer:image-processing:1
Attach it to your function:
aws lambda update-function-configuration \
--function-name my-image-function \
--layers arn:aws:lambda:us-east-1:123456789012:layer:image-processing:1
Your function code now imports Pillow normally — no bundling needed:
from PIL import Image
import numpy as np
import io
def handler(event, context):
# Pillow and numpy come from the layer
img = Image.open(io.BytesIO(event['image_bytes']))
arr = np.array(img)
return {"shape": arr.shape}
Creating a Node.js Shared Utilities Layer
Python isn’t the only use case. Node.js teams often have shared utility code — logging helpers, API response formatters, common validation logic — scattered across dozens of functions. A layer is the right place for that.
Build the Node.js layer:
mkdir -p nodejs/node_modules
cd nodejs
npm install lodash axios winston --prefix .
cd ..
zip -r utils-layer.zip nodejs/
Publish it:
aws lambda publish-layer-version \
--layer-name shared-utils \
--description "Lodash, Axios, Winston logger" \
--compatible-runtimes nodejs20.x nodejs22.x \
--compatible-architectures x86_64 arm64 \
--zip-file fileb://utils-layer.zip \
--region us-east-1
For your internal utility code, the approach is the same. Build your module, put it in nodejs/node_modules/your-module/, and it becomes importable from any function that attaches the layer:
// This resolves from the layer's node_modules
const { formatApiResponse } = require('your-module');
const winston = require('winston');
const logger = winston.createLogger({ level: 'info' });
exports.handler = async (event) => {
logger.info('Processing event', { event });
return formatApiResponse(200, { status: 'ok' });
};
One layer, shared across every Node.js function that needs it.
Custom Runtimes: When and Why
Lambda supports a fixed set of managed runtimes: Python, Node.js, Java, Go (deprecated in managed form), .NET, Ruby. If you want to run something else — Rust, a custom Go binary, PHP, a specific LLVM-compiled language — you need a custom runtime.
A custom runtime is a layer that contains an executable called bootstrap. Lambda invokes bootstrap on startup, and your bootstrap binary is responsible for polling the Lambda Runtime API, executing the function handler, and posting the result back.
The Lambda Runtime API is a local HTTP endpoint exposed inside the execution environment:
GET http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next
POST http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/{requestId}/response
POST http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/{requestId}/error
Your bootstrap binary loops forever: fetch the next event, process it, post the response.
Rust is the most compelling custom runtime use case in 2026. Rust cold starts on Lambda are consistently under 5ms with memory footprints under 5MB. Compare that to a JVM-based Lambda: 500ms+ cold start, 256MB minimum memory just for the runtime. For latency-sensitive event processing or functions invoked millions of times per day, the cost and performance difference is significant.
The lambda_runtime crate handles the Runtime API boilerplate:
[dependencies]
lambda_runtime = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros"] }
use lambda_runtime::{service_fn, Error, LambdaEvent};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Request {
user_id: String,
}
#[derive(Serialize)]
struct Response {
message: String,
}
async fn handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
Ok(Response {
message: format!("Hello, {}", event.payload.user_id),
})
}
#[tokio::main]
async fn main() -> Result<(), Error> {
lambda_runtime::run(service_fn(handler)).await
}
Build for Lambda’s Linux target:
cargo build \
--release \
--target x86_64-unknown-linux-musl
# The bootstrap binary goes in the zip
cp target/x86_64-unknown-linux-musl/release/my-function bootstrap
zip lambda.zip bootstrap
aws lambda update-function-code \
--function-name my-rust-function \
--zip-file fileb://lambda.zip
The function has no managed runtime — set --runtime provided.al2023 in the function configuration.
Go compiled binaries work the same way, though AWS’s managed Go runtime was retired. Teams running Go on Lambda now use the custom runtime approach with provided.al2023.
Lambda Container Images vs Layers: Decision Framework
Container images are the alternative to zip-based deployment for Lambda. Your function is packaged as a Docker image up to 10GB. AWS caches container images in a regional cache, so cold starts are comparable to zip deployments for images up to a few hundred MB.
When to use layers:
- Dependencies under 250MB unzipped total
- You want to share libraries across multiple functions
- Your team deploys frequently and wants fast upload cycles
- Simple CI/CD pipelines without a container registry
When to use container images:
- Dependency size exceeds 250MB unzipped
- You need exact control over the OS environment (specific glibc version, system packages)
- Your team already has a Docker-based workflow and pushes to ECR — see our GitLab CI build and push to ECR guide for how to wire that up
- You need to include binaries that have complex shared library dependencies
- You’re migrating an existing containerized service to Lambda without rewrites
The container image approach doesn’t use layers. You build the image with all dependencies baked in, push to ECR, and point the Lambda function at the image URI. It’s simpler from a packaging standpoint, but you give up the sharing benefit that layers provide.
My rule of thumb: start with zip + layers. Switch to container images when you hit size limits or need OS-level control.
Layer Versioning and Cross-Account Sharing
Every time you publish a layer, AWS creates a new immutable version. The version number is an integer that increments. The ARN encodes the version:
arn:aws:lambda:us-east-1:123456789012:layer:image-processing:7
Versions never change. If you publish version 7, it is version 7 forever. This means existing functions attached to version 6 keep working exactly as before until you explicitly update them.
When you’re ready to roll out a new version across your functions:
# Update all functions that use the old layer version
FUNCTIONS=(function-a function-b function-c)
NEW_LAYER_ARN="arn:aws:lambda:us-east-1:123456789012:layer:image-processing:8"
for fn in "${FUNCTIONS[@]}"; do
aws lambda update-function-configuration \
--function-name "$fn" \
--layers "$NEW_LAYER_ARN"
done
For cross-account sharing, you grant permission on the specific layer version:
# Allow another AWS account (or all accounts) to use your layer
aws lambda add-layer-version-permission \
--layer-name image-processing \
--version-number 8 \
--statement-id allow-org-access \
--action lambda:GetLayerVersion \
--principal "*" \
--organization-id o-your-org-id
Using --principal "*" with --organization-id restricts access to your AWS Organization. Leave off --organization-id and you make the layer public — useful for open-source utility layers but usually not what you want for internal libraries.
Teams I’ve seen manage this well define a “platform layers” account that owns the published layers. Application accounts pull from that account’s layer ARNs. One place to update, one place to audit.
SAM and CloudFormation Templates
Managing layers manually with the CLI is fine for one-off setups, but you want IaC for anything production. Here’s a SAM template that defines a layer and a function that uses it:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Lambda function with shared dependency layer
Resources:
ImageProcessingLayer:
Type: AWS::Serverless::LayerVersion
Properties:
LayerName: image-processing
Description: Pillow and numpy for image processing Lambda functions
ContentUri: layers/image-processing/
CompatibleRuntimes:
- python3.12
CompatibleArchitectures:
- x86_64
RetentionPolicy: Retain
Metadata:
BuildMethod: python3.12
BuildArchitecture: x86_64
ImageProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: image-processor
CodeUri: src/image_processor/
Handler: handler.lambda_handler
Runtime: python3.12
Architectures:
- x86_64
MemorySize: 512
Timeout: 30
Layers:
- !Ref ImageProcessingLayer
Environment:
Variables:
LOG_LEVEL: INFO
Outputs:
LayerArn:
Description: ARN of the image processing layer
Value: !Ref ImageProcessingLayer
Export:
Name: ImageProcessingLayerArn
The Retain retention policy keeps old layer versions around when you delete the stack. Without it, SAM deletes the layer version on stack deletion, which breaks any function outside this stack that references it. Always use Retain for layers shared across stacks.
To deploy:
sam build
sam deploy \
--stack-name image-processor-stack \
--resolve-s3 \
--capabilities CAPABILITY_IAM
sam build handles the layer packaging — it runs pip install with the correct architecture target and structures the output directory correctly. No manual zip steps.
Cost and Cold Start Implications
Layers themselves have no cost. You pay for storage at the standard S3 pricing rate ($0.023/GB/month), but a 50MB layer costs fractions of a cent. The cost benefit comes from function efficiency, not layer pricing.
Memory determines both cost and cold start duration. Lambda pricing is (requests × duration_ms × memory_GB). A 256MB function that runs for 500ms costs half as much as a 512MB function with the same duration.
Layers don’t directly reduce cold starts. What they change is the initialization time of your function code. A function that doesn’t bundle large libraries has a smaller deployment package, which means Lambda retrieves it faster. But the actual cold start is dominated by the runtime initialization — JVM startup, Python interpreter loading, etc.
For cold start minimization, the order of impact is:
- Runtime choice: Rust/Go custom runtime (5-15ms) » Python/Node.js (100-500ms) » Java (500ms-2s)
- Memory allocation: More memory = faster cold start, even if you don’t need the RAM
- Function code size: Smaller is marginally faster to retrieve, but not a major factor
- Layers: No meaningful cold start impact vs. bundling the same code in the zip
Provisioned Concurrency is the real solution to cold starts for latency-sensitive functions. Layers don’t change that calculus.
Common Mistakes
Architecture mismatches. This is the single most common reason layers fail in production. You build on a Mac (arm64 via Apple Silicon), the package compiles native extensions for darwin/arm64, and then it fails at runtime on Lambda’s x86_64 Linux environment. Always build inside Docker targeting linux/x86_64 or linux/arm64 to match your function’s architecture. If you’re moving a function from x86_64 to arm64 for the Graviton3 cost savings, you must rebuild every layer with native extensions.
Exceeding the 250MB limit. Lambda enforces a 250MB unzipped limit across all layers attached to a function, plus the function code itself. One layer with numpy + scipy + Pillow can hit 200MB alone. If you’re close to the limit, container images are the right answer, not trying to trim libraries.
Too many layers. Lambda allows up to five layers per function. I’ve seen architectures where someone put every library in its own layer — one for Pillow, one for boto3, one for requests — to maximize reuse. The reuse argument doesn’t hold up: boto3 and requests ship with the managed Python runtime already. Adding them as a layer wastes one of your five slots. Reserve layers for genuinely large or genuinely shared dependencies.
Stale layer versions. A team publishes a security-patched layer version but forgets to update three of twelve functions. Those three functions keep running the old, vulnerable version for months. Put layer version management in your IaC. If the version is hardcoded in a template that’s in source control, it’s visible and auditable. If it’s set manually through the console, it’s invisible until it causes a problem.
Forgetting /opt/ paths for binaries. When a custom runtime or binary layer needs to reference a shared library, the library must be in /opt/lib/. Lambda adds this to LD_LIBRARY_PATH, but only at startup. If your bootstrap binary dynamically links against a .so file that’s not in /opt/lib/ or the standard system paths, it’ll fail at invocation with a missing library error.
Putting It Together
AWS Lambda layers are a mature, reliable tool that solves a specific problem: keeping function code separate from heavy dependencies, and sharing those dependencies across functions without duplication.
The workflow is predictable. Build dependencies on matching Linux architecture, zip with the correct directory structure, publish with the CLI or SAM, reference the ARN in your function config. Custom runtimes follow the same pattern, just with a bootstrap binary doing the Runtime API loop instead of a managed runtime doing it for you.
The decision between layers, custom runtimes, and container images comes down to size and control. Layers for shared libraries under 250MB. Custom runtimes for languages Lambda doesn’t support natively, especially Rust for performance-critical functions. Container images when size or OS environment requirements push past what layers can handle.
For S3-integrated image processing workflows — where Lambda receives events from S3, processes images through Pillow, and writes results back — layers are the natural fit. We cover that pattern in depth in the AWS Lambda Pillow guide. For access control on the S3 side of those workflows, S3 Access Points and Object Lambda Access Points let you transform data in-flight without changing your function code.
The underlying point is simple: Lambda’s packaging model rewards separation of concerns. Keep function logic small, keep dependencies in layers, and keep the architecture consistent. Everything else follows from that.
Related Posts
For image processing on Lambda with native Python libraries, see our full guide on AWS Lambda Pillow for Complex Image Processing.
Building event-driven workflows where Lambda is one step in a longer pipeline? EventBridge and Step Functions patterns covers how to orchestrate Lambda functions with durable state.
If you’re pushing container images to ECR as part of a CI/CD pipeline, GitLab CI Build Docker Image and Push to ECR has the pipeline config you need.
For S3-based workflows that call Lambda through access points, see S3 Access Points and Object Lambda Access Points.
Comments