Using uv for AWS Lambda lets you build Python dependencies that behave in the cloud exactly as they did in your tests: reproducible from a lockfile, matched to the Lambda architecture and done in seconds rather than minutes. If you have ever deployed a function that was green locally and then crashed in AWS with a cryptic ImportError, you know why this matters.
I care about this topic because Python packaging for Lambda used to be the least loved part of an otherwise pleasant serverless project. Plugins, Docker containers that crawl on a Mac, and in the end still a wrong binary inside the zip. uv has made this a lot easier. In this article you will build, step by step, a small Python function with a dependency that ships native code, and deploy it with the Serverless Framework.
In short
uv turns
uv.lockinto an exact list of your runtime dependencies and uses--python-platformto install them as Linux wheels for your function's architecture, even when you work on a Mac or a Windows machine. That folder becomes a Lambda layer which the Serverless Framework deploys for you. The most common pitfall is a mismatch between Python version and architecture, which only shows up on the first invocation in AWS.
What you will learn in this article
- Why local Python packages can fail on Lambda and how platform tags help
- How to build an arm64 layer with uv export and uv pip install
- How to deploy and test the layer with the Serverless Framework
- When the framework's built-in Python support is good enough
- How to spot the typical packaging errors right away
Why use uv for AWS Lambda?
uv is a package and project manager for Python, written in Rust. In many projects it replaces pip, pip-tools, virtualenv and pyenv in one go. Three of its properties matter for Lambda. First, uv writes a lockfile (uv.lock) that pins every dependency to an exact version and checksum. Second, it can install packages for a platform other than the one it is running on. Third, it is fast enough that the build step barely registers in your pipeline.
Especially the second point is the reason I now recommend uv for AWS Lambda to every Python team. Before, reliable builds almost always required Docker or a CI runner with the same architecture as the function. Now a single command with the right options is enough.
The real problem: your laptop is not Lambda
Many popular Python packages contain compiled code, for example pydantic (via pydantic-core), numpy, cryptography or orjson. Such packages are distributed as wheels: prebuilt archives made for a specific operating system, CPU architecture and Python version. Which combination a wheel supports is encoded in its file name, in the so-called platform tag.
A tag like manylinux_2_28_aarch64 means Linux on ARM64 with at least glibc 2.28. Lambda functions run on Amazon Linux, and the current Python runtimes on Amazon Linux 2023. If you run pip install on a MacBook, however, you get macOS wheels in your folder. Although the Python code inside looks identical, the binary is useless on Lambda. However, you only notice when the function is invoked for the first time.
On top of that, the Python version has to match. A wheel with cp314 in its name belongs to Python 3.14. For example, if you build the layer with 3.12 and run the function on python3.14, the import fails just the same. The AWS documentation on Python layers says so explicitly: layer and function should use the same Python version.
What matters is which platform the wheels are built for, not where the build runs.
Prerequisites
- uv 0.13 or later (tested with 0.13.7), see the uv docs for installation
- Node.js and Serverless Framework v4 (
npm i -g serverless) - An AWS account with credentials for the
eu-central-1region -
curlto test the finished API
Also, you do not need Docker for this approach. That is deliberate: the build should produce the same result on a Mac with Apple silicon, on Windows and on an x86 runner in GitHub Actions.
Step 1: Create the project with uv
The example is a small bakery that takes pre-orders for bread rolls and pretzels. The function validates the order with pydantic and calculates the price. First create the project and add the dependencies:
mkdir bakery && cd bakery
uv init --bare --python 3.14
uv add "pydantic>=2.12"
uv add --dev pytest
uv init --bare only creates a minimal pyproject.toml without sample code. Then add two settings by hand so that the file looks like this:
# pyproject.toml
[project]
name = "bakery-preorder"
version = "0.1.0"
description = "Example: packaging a Python Lambda with uv"
requires-python = ">=3.14"
dependencies = [
"pydantic>=2.12",
]
[dependency-groups]
dev = [
"pytest>=8",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
pythonpath = ["."]
package = false tells uv that your project itself is not an installable package. As a result it will not show up as a dependency in the export later. The dev group holds everything you only need locally or in the pipeline. Therefore it never ends up in the layer.
The handler
The handler reads the request body, validates it and returns the price. From ten pieces there is a ten percent discount. Invalid orders get status 400 along with pydantic's validation errors.
# bakery/handler.py
import json
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
UNIT_PRICE = {"roll": Decimal("0.45"), "pretzel": Decimal("0.80")}
class PreOrder(BaseModel):
item: Literal["roll", "pretzel"]
quantity: int = Field(ge=1, le=200)
def calculate_price(order: PreOrder) -> Decimal:
"""From 10 pieces there is a 10 percent discount on the whole order."""
total = UNIT_PRICE[order.item] * order.quantity
if order.quantity >= 10:
total *= Decimal("0.9")
return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def handler(event, context):
try:
order = PreOrder.model_validate_json(event.get("body") or "{}")
except ValidationError as error:
return {
"statusCode": 400,
"headers": {"content-type": "application/json"},
"body": json.dumps({"errors": error.errors(include_url=False)}, default=str),
}
return {
"statusCode": 200,
"headers": {"content-type": "application/json"},
"body": json.dumps({
"item": order.item,
"quantity": order.quantity,
"price_eur": str(calculate_price(order)),
}),
}
Also create an empty file bakery/__init__.py. Three tests cover the logic:
# tests/test_handler.py
import json
from bakery.handler import handler
def order(body: dict) -> dict:
return handler({"body": json.dumps(body)}, None)
def test_price_for_three_pretzels():
response = order({"item": "pretzel", "quantity": 3})
assert response["statusCode"] == 200
assert json.loads(response["body"])["price_eur"] == "2.40"
def test_discount_from_ten_pieces():
response = order({"item": "roll", "quantity": 12})
# 12 x 0.45 = 5.40 minus 10 percent = 4.86
assert json.loads(response["body"])["price_eur"] == "4.86"
def test_unknown_item_returns_400():
response = order({"item": "croissant", "quantity": 1})
assert response["statusCode"] == 400
uv run pytest -q
Expected result: 3 passed. uv creates a virtual environment in .venv automatically and even downloads Python 3.14 if needed. So far this is plain, ordinary Python.
Step 2: Build the dependencies as a Lambda layer
Now for the part this article is really about. A Lambda layer is a zip archive with code or libraries that Lambda places next to your function at startup. For Python, the archive must contain a python folder at the top level, because the runtime adds that path to the import path automatically. The following script builds exactly that folder:
#!/usr/bin/env bash
# scripts/build_layer.sh โ builds the dependencies as a Lambda layer
set -euo pipefail
PYTHON_VERSION="${PYTHON_VERSION:-3.14}"
PLATFORM="${PLATFORM:-aarch64-manylinux_2_28}" # x86_64-manylinux_2_28 for x86_64
TARGET="build/layer/python"
rm -rf build/layer
mkdir -p "$TARGET"
# 1. Export only the runtime dependencies from uv.lock
uv export --frozen --no-dev --no-emit-project -o build/requirements.txt
# 2. Install matching Linux wheels for the Lambda platform
uv pip install \
--requirement build/requirements.txt \
--target "$TARGET" \
--python-platform "$PLATFORM" \
--python-version "$PYTHON_VERSION" \
--only-binary :all: \
--require-hashes \
--no-installer-metadata \
--compile-bytecode
echo "Layer ready: $(du -sh build/layer | cut -f1) in build/layer"
What the options do
The script works in two steps. uv export writes a classic requirements.txt from the lockfile, containing only the runtime dependencies and their checksums. Then uv pip install installs that list into the target folder, for the given platform instead of your own machine. Here are the key options:
| Option | What it does |
|---|---|
--frozen |
Uses uv.lock as is and does not resolve again |
--no-dev |
Leaves out pytest and other development tools |
--no-emit-project |
Keeps your own project out of the list, since your code goes into the function zip separately |
--python-platform |
Picks wheels for Linux and the target architecture, here aarch64-manylinux_2_28
|
--python-version |
Picks wheels for the runtime's Python version |
--only-binary :all: |
Forbids building from source, so nothing gets compiled for your machine by accident |
--require-hashes |
Only installs files whose checksum matches the lockfile |
--compile-bytecode |
Creates .pyc files ahead of time, because Lambda cannot write them at runtime |
Why manylinux_2_28?
Why manylinux_2_28 and not the older manylinux2014 found in many guides? Amazon Linux 2023 ships glibc 2.34. With manylinux2014 (glibc 2.17) you needlessly limit yourself to wheels for very old systems, and some projects no longer publish those at all. manylinux_2_28 is a good middle ground, but uv also knows aarch64-manylinux_2_34 if you want to match the operating system exactly.
Check the result
chmod +x scripts/build_layer.sh
./scripts/build_layer.sh
At the end the script prints something like Layer ready: 11M in build/layer. After that, quickly check that it really contains ARM code:
file build/layer/python/pydantic_core/*.so
The output should say ARM aarch64, and the file name ends in cpython-314-aarch64-linux-gnu.so. This is exactly the file that a regular pip install on your laptop would have replaced with a macOS or x86 variant.
๐ฌ From lockfile to Lambda layer โ this animated diagram only runs on my site: watch it on sascha-ms.de
Step 3: Deploy uv for AWS Lambda with the Serverless Framework
The Serverless Framework can publish a ready-made folder as a layer directly. The function itself then only contains your own code, a few kilobytes in size. The complete serverless.yml:
# serverless.yml
service: bakery
frameworkVersion: "4"
provider:
name: aws
runtime: python3.14
architecture: arm64
region: eu-central-1
memorySize: 512
timeout: 10
logRetentionInDays: 14
package:
patterns:
- "!**"
- "bakery/**"
- "!**/__pycache__/**"
layers:
dependencies:
path: build/layer
name: bakery-${sls:stage}-deps
description: pydantic & co. from uv.lock
compatibleRuntimes:
- python3.14
compatibleArchitectures:
- arm64
functions:
price:
handler: bakery.handler.handler
layers:
- !Ref DependenciesLambdaLayer
events:
- httpApi:
path: /price
method: post
A few details matter. Under package.patterns, "!**" first excludes everything, and then only the bakery folder is added back. As a result, .venv, build and the tests stay out of the function zip. The framework turns the layer entry dependencies into the CloudFormation resource DependenciesLambdaLayer, which the function references via !Ref. Important: architecture and runtime must match PLATFORM and PYTHON_VERSION in the build script.
Deploy and test
./scripts/build_layer.sh
serverless deploy
After the deploy, the framework prints the URL of the HTTP API. Send it an order:
curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/price" \
-H "content-type: application/json" \
-d '{"item": "pretzel", "quantity": 10}'
Expected response: {"item": "pretzel", "quantity": 10, "price_eur": "7.20"}. With "item": "croissant" you get status 400 and a list of errors from pydantic. Above all, this proves that the native library really loads inside Lambda.
Test the layer locally
One small trick saves you deploys: test the built layer locally before uploading it. The following script loads the handler using only the contents of build/layer/python. For this, build the layer once for your own machine's platform, on an x86 Linux system that means PLATFORM=x86_64-manylinux_2_28. This local test does not work on macOS, because Linux wheels do not load there.
# scripts/smoke_test.py โ calls the handler using only the layer contents
import json
import sys
sys.path[:0] = ["build/layer/python", "."]
from bakery.handler import handler # noqa: E402
event = {"body": json.dumps({"item": "pretzel", "quantity": 10})}
print(handler(event, None))
Alternative: the framework's built-in Python support
Serverless Framework v4 has Python packaging built in, which used to require the serverless-python-requirements plugin. According to the documentation, it detects a uv.lock, exports a requirements list from it with uv export --no-dev --frozen --no-hashes, and with installer: uv it can hand the installation over to uv as well. With layer: true it creates the layer itself:
# serverless.yml (excerpt, variant with built-in support)
custom:
pythonRequirements:
installer: uv
layer: true
slim: true
dockerizePip: non-linux
functions:
price:
handler: bakery.handler.handler
layers:
- Ref: PythonRequirementsLambdaLayer
Where the built-in variant falls short
The catch is the platform. To get matching Linux wheels, the framework relies on Docker (dockerizePip) or on pip options such as --platform in pipCmdExtraArgs. Whether those pip options are passed on to uv pip install is not stated in the documentation, and uv uses different option names for this anyway. Docker works reliably; however, on an x86 machine an arm64 build then needs emulation and is correspondingly slow.
My rule of thumb: for a quick experiment the built-in variant is convenient. As soon as the project moves into a pipeline, I use my own build script. It is twenty lines long, I can see every option, and it runs on any machine without Docker in the same amount of time.
What about AWS CDK or container images?
The layer folder does not care which tool deploys it. In AWS CDK you pass it to a LayerVersion with lambda.Code.fromAsset('build/layer'), and the build script and options stay the same. If your package grows beyond the 250 MB zip limit (unzipped, function and layers combined), a container image is worth it. The uv documentation shows a multi-stage Dockerfile for that, which uses the same uv export step.
Typical errors and how to spot them
ImportError on the first invocation
Messages such as No module named 'pydantic_core._pydantic_core' or cannot import name 'ArgsKwargs' from 'pydantic_core._pydantic_core' almost always mean: wrong architecture or wrong Python version in the layer. Both are easy to reproduce: an arm64 layer on an x86 machine or a 3.12 layer under Python 3.14 produces exactly these messages. The fix is always the same: align PLATFORM and PYTHON_VERSION in the script with architecture and runtime in serverless.yml.
No matching wheel found
If uv pip install aborts saying there is no matching distribution, the package only ships source code for your combination. Because of --only-binary :all:, uv deliberately builds nothing. First, check whether a newer version comes with wheels. If not, keep that build in a Linux container with the same architecture, for example on an arm64 runner in your CI.
Lockfile out of sync with pyproject.toml
If you changed a dependency in pyproject.toml but forgot uv lock, --frozen silently exports the old state. In the pipeline it is therefore worth running uv lock --check before the build: the command fails when the lockfile is outdated.
boto3 twice in the package
The Python runtime already includes boto3. You can still add it to your dependencies if you want a pinned version, which is sensible for production. In that case it belongs in dependencies on purpose, not in the layer by accident via the dev group.
Cost and security
Layers themselves cost nothing extra. Every deploy with changed contents creates a new layer version, though, and all versions count against the Lambda code storage quota in your account. How to get rid of old versions automatically is covered in my article Cleaning up Lambda versions. Runtime cost, on the other hand, depends on memory and duration. You can play through how arm64 and a few milliseconds less cold start affect the bill in the Lambda cost calculator.
For security, the script helps twice. --require-hashes makes sure that only files whose checksums are in the lockfile get installed. A tampered file, say from a compromised package mirror, is caught immediately. In addition, since uv.lock lives in the repository, you see every version change in the pull request. You pull updates selectively with uv lock --upgrade-package pydantic. If you also want to know which function uses which library, an SBOM for Lambda helps.
My take
For me, uv is the first Python tool where packaging for Lambda no longer feels like a compromise. The --python-platform trick takes Docker out of the daily routine, and the lockfile makes sure tests and production really see the same versions. That is exactly what I mean by a serverless mindset: less infrastructure on your mind, more focus on the actual code.
Two limitations belong to an honest picture. Packages without wheels remain tedious, and uv does not change that. Moreover, the platform options are no substitute for a real test in AWS. A smoke test after every deploy, such as the curl call above in your pipeline, costs next to nothing and catches the last surprises. If you also want to observe your functions, Lambda Powertools for Python is the next sensible building block.
FAQ
Do I need Docker to build Python packages for Lambda?
No, as long as all dependencies offer Linux wheels. With --python-platform and --python-version, uv installs matching Linux wheels even on macOS or Windows. You only need Docker for packages that are available as source code only.
Which platform do I specify for Lambda arm64?
For arm64 (Graviton), for example aarch64-manylinux_2_28, for x86_64 x86_64-manylinux_2_28. Both fit the current Python runtimes on Amazon Linux 2023.
Layer or everything in one zip?
Both work. Still, a layer separates rarely changing dependencies from your code, deploys get smaller, and several functions can share the same layer. For a single function, one combined zip is just as fine.
Does the Serverless Framework support uv directly?
Yes. Version 4 detects a uv.lock, exports the dependencies from it and, with installer: uv, can also hand the installation over to uv. For the right target platform, the built-in variant relies on Docker or pip options.
Your next step
Pick an existing Python function, replace its requirements.txt with uv add and build the layer with the script from this article. Then compare build time and package size with your current approach. If you are already on python3.14, my article on SnapStart for Python is a good companion.
How do you package your Python Lambdas today: with Docker, with a plugin or already with uv? Let me know in the comments.
All code from this article, ready to run: smirowstanitzok/sascha-ms-examples
Sources
- uv documentation: Using uv with AWS Lambda
- uv documentation: CLI reference (uv export, uv pip install)
- Serverless Framework: Python Support
- AWS Lambda: Working with layers for Python
- AWS Lambda: Lambda runtimes
This post was created with AI assistance.
๐ Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator โ it runs right in your browser.
๐ฌ Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)