When I originally wrote Deploy a Python Lambda on AWS with uv and Terraform, I only wanted to see a working solution to package an AWS Lambda with uv, and deploy with Terraform. It was done, but I kept thinking about making the process better.
A couple of things I wanted to see:
CI/CD (GitHub Actions) support
One bash command per lambda (could be wrapped in another bash script to call the packaging one in a loop)
Reproducible builds (zip file hashes) - prevents a Terraform redeploy if the lambda's source code did not change
Dependencies as an internal zip
With the requirements set, here's what I tried, step by step, and what I ended up with.
The initial implementation
Before we dive in, let me recap what I ended up with in my initial try. The starting point is a single lambda with its set of dependencies, and a virtual environment managed by uv.
The build itself was these steps:
Export dependencies with uv (this creates a
requirements.txtfile).Install dependencies into a
buildfolder using uv. This does not create a virtual environment (there is no python executable). It only installs dependencies into a folder.The folder's contents are packaged into a zip archive.
The lambda's
app(sources) folder is added to the existing archive.
The bash script I ended up with only automates the process a little. It also cleans up the intermediate results (like the folder with dependencies), so I end up with a clean workspace after packaging a lambda.
While it works, and could even be scaled a bit to a multi-lambda repository, it does not take care of the zip-inside-zip structure needed for the 250 MB AWS Lambda source code size limit. It also does not produce a reproducible build (a zip archive's hash would be different every time, even if the lambda's source code did not change).
Existing solutions
package-python-function
There is this Python CLI: package-python-function.
And it's interesting for a few reasons:
It is a standalone CLI tool that can be used against any project structure
It supports reproducible builds - the zip archive always has the same hash if the source code did not change
It supports zip-inside-zip - a workaround to help with the 250 MB lambda source code size limit
Zip-inside-zip - improved
The idea comes from the serverless-python-requirements project, but it requires these lines to be present in the entry point file:
try:
import unzip_requirements
except ImportError:
passThis causes a few problems:
You need to remember to include the lines
The lines should be the first lines of your handler file
IDEs, in my experience, always try to insert imports above this code block, practically breaking the lambda function
So, unlike serverless-python-requirements, package-python-function does zipping, and unzipping automatically. The source code stays clean.
How it works
My setup is pretty much the same as in the previous post: a Python project, uv for dependency management, and a .venv folder with a virtual environment where all dependencies are installed.
The CLI's readme says it packages the virtual environment with all dependencies installed. So, the first thing I tried was to package the .venv, and it did not work. Well, it did work, but what got packaged was all of the dependencies, and not the lambda's code.
poetry-plugin-bundle's role
At this stage I had two questions: how to get my lambda's code into the virtual environment without breaking my existing setup, and what poetry-plugin-bundle actually does.
So, poetry-plugin-bundle bundles the project with its dependencies into various formats.
It does pretty much what I am after. But it's a Poetry plugin, and I'm on uv - switching package managers just for this felt like overkill, so I kept looking.
What's missing in my setup
What is missing is the project bundling with the dependencies. And all of that should work with various setups, so that the process does not have the folder name, file lists, or anything like that anywhere in it.
hatch(ling) + hatch-aws
Before creating something new, I wanted to see if there is a solution already. Searching for one made me discover Hatch - a Python project management solution that had its build system.
And also, I found hatch-aws, the Hatch plugin for packaging things for AWS.
I tried a couple of times with no result. To be honest, I probably could have gotten it working with more effort, but looking closer at hatch-aws, I realized that the lambda setup is not what I would prefer to do.
Let me elaborate. Reading the documentation for hatch-aws, I saw 2 lambdas and only one pyproject.toml file. This ends up with:
A single virtual environment locally, and potentially some conflicting dependencies between lambdas.
The need for a SAM (Serverless Application Model) template, which means lambdas are managed via CloudFormation instead of the Terraform deployment I'm targeting.
Tricky dependency management for each lambda at the packaging time, or a single shared package for both lambdas holding everything.
As a result, I decided not to reuse hatch-aws. I could probably go and develop my own plugin for Hatch to package the lambda the way I want. But, would it also make the required lambda structure set in stone?
I may get back to the idea of a Hatch build plugin in the future, but for now, I want to stick to uv's fast build backend, and reuse package-python-function.
Packaging a Lambda with uv and package-python-function
In the end, making something on my own was inevitable, so let's make everything work now.
I will start by going back to the lambda I created in the first post about packaging the lambda with uv: Deploy a Python Lambda on AWS with uv and Terraform.
The repository is located here: dhrimov/demo-aws-lambda-terraform-uv, and it is now updated with this post's work (the packaging script only, not the full workflow). If you want to follow along, make sure to check out the v1.0.0, with the starting state.
First, let's update pyproject.toml. There are a couple of things that need to be fixed.
pyproject.toml: project name
Now that I intend to install the project (I still don't plan to distribute or publish it), the distribution name plays a bigger role than before.
uv's build backend derives a module name from project.name: it lowercases the name, and replaces dots and dashes with underscores. That module name is the folder it expects to find under module-root (src/ by default, the project root in this flat layout). You can override it explicitly with module-name in [tool.uv.build-backend], but here I am leaving it to derive from project.name, so the project name has to normalize to app for uv to find the app/ folder.
package-python-function does its own normalization to compute a distribution_name (any non-alphanumeric character in project.name becomes an underscore). It reuses that same value as entrypoint_package_name - the folder name it writes the zip-inside-zip loader into, once the packaged output crosses the 250 MB uncompressed limit. At the 0.0.12 release, there is no separate setting for it.
So if project.name does not normalize to the same folder uv actually built (app), the loader ends up in a folder Lambda's Init sequence never imports. The zip-inside-zip fallback then silently ships a .dependencies.zip that never gets extracted.
So, let's update the project name, and leave a comment explaining why we do that:
# The distribution name deliberately matches the entrypoint package directory
# (app/). package-python-function derives the package name for its nested-zip
# loader from the distribution name, so if the two ever diverge, the >250 MiB
# fallback ships a loader the handler never imports.
name = "app"pyproject.toml: build system
Ok, let's now specify the build-system we will be using - raw uv across the board:
[build-system]
requires = ["uv_build>=0.8,<0.9"]
build-backend = "uv_build"And, since the project structure is a little unusual for a lambda (using a flat layout), we need to customize the build parameters just a little bit:
[tool.uv.build-backend]
# module-name is derived from the project name. Only the flat layout needs
# spelling out, since uv defaults to src/.
module-root = ""This tells the build backend where to look for the app/ module folder. module-root defaults to src/, so uv would normally expect src/app/. Setting it to an empty string points uv at the project root instead - the same directory as pyproject.toml - which matches this repo's flat layout.
Once pyproject.toml updates are done, make sure to run the lock command (run from the project root):
uv lockMaking app a module
uv's build backend expects the module folder to be an actual Python package, not a namespace package. The docs put it plainly: a module is a directory containing an __init__.py. So far, app/ has just been a plain source folder for the lambda handler - it never needed one.
So, we create an empty __init__.py in the app/ folder, making it a root module of our lambda app now.
The Lambda packaging script
This is where the missing piece from earlier comes in: instead of hoping package-python-function can guess where the lambda's code lives, I install the project itself as a wheel, right alongside its dependencies, into the throwaway venv that gets packaged - no folder names or file lists hardcoded anywhere.
Now we can package our lambda. The process consists of these steps:
Export locked dependencies, and exclude dev dependencies
Build the lambda project as a wheel
Install the built lambda project and exported dependencies into a throwaway virtual environment, targeting the required platform
Package that throwaway virtual environment into a zip archive using
package-python-function
Since I want this fully automated and easy to reuse, packaging happens as a bash script instead of a list of manual commands. The full version lives in the dhrimov/demo-aws-lambda-terraform-uv repository. It has more customization, argument parsing, usage help, and more. Here is a simplified version, so you get the idea:
#!/usr/bin/env bash
set -euo pipefail
INPUT="."
OUTPUT="terraform"
PYTHON_VERSION="3.13"
PLATFORM="aarch64-manylinux2014"
BUILD_DIR="$INPUT/build"
PACKAGER_VERSION="0.0.12"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$OUTPUT"
echo "==> Exporting locked dependencies"
uv export --directory "$INPUT" --frozen --no-dev --no-editable --no-emit-project \
-o "$BUILD_DIR/requirements.txt"
echo "==> Building wheel"
uv build --directory "$INPUT" --wheel -o "$BUILD_DIR/dist"
echo "==> Creating venv"
uv venv --python "$PYTHON_VERSION" "$BUILD_DIR/venv"
echo "==> Installing wheel and dependencies for $PLATFORM"
uv pip install \
--python "$BUILD_DIR/venv/bin/python" \
--python-platform "$PLATFORM" \
--only-binary=:all: \
"$BUILD_DIR"/dist/*.whl \
-r "$BUILD_DIR/requirements.txt"
echo "==> Packaging into a zip"
uvx "package-python-function@$PACKAGER_VERSION" "$BUILD_DIR/venv" \
--project "$INPUT/pyproject.toml" \
--output-dir "$OUTPUT"
rm -rf "$BUILD_DIR"The script builds a terraform/app.zip file. Now we update lambda.zip to app.zip in the demo-lambda.tf file so the apply works. I am not going through the Terraform deployment because this post is about packaging. But I did deploy to make sure it works.
The architecture and the runtime are pinned in two places, and they have to agree: --platform/--python in the packaging script, and architectures/runtime in demo-lambda.tf. If they drift apart, the lambda deploys fine and fails at runtime.
Try it with a couple of lambdas
Since I started by changing the existing repo, I had some weird decisions to make, like renaming the project to app to get dependency zipping working, and not causing a huge refactor. Also, the existing repo only has a single lambda, in the repository root folder.
I wanted to test this out with a different lambda structure (e.g. a project with multiple lambdas), as well as keep this solution in mind to name things appropriately from the beginning.
I won't guide you through each step I took to get there, but I am going to share a repository for you to take a look at if you are interested: dhrimov/demo-aws-lambda-packaging-with-uv.
Key things to note about the repository:
It has two lambdas with pretty much no dependencies - just
pydanticandpydantic-settings, to add an environment variables model and give the example some real imports to work with.Each lambda has its own set of dependencies, and each lambda results in its own package.
It has a
terraform/folder, but no infrastructure in it yet. It is used as the packaging script's output directory for now, and if you follow the steps in the first article, it is straightforward to add the Terraform files to get it working end-to-end.
Final words
Looking back at the list I started with - CI/CD support, one bash command per lambda, reproducible builds, and dependencies as an internal zip - I got three out of four done. The packaging script is one command per lambda, the output zip is reproducible (same source, same hash), and package-python-function takes care of the internal zip automatically once a lambda's dependencies grow past the 250 MB limit.
CI/CD is still open. Wiring this packaging script into a GitHub Actions workflow is the natural next step, and I would rather give it its own post than squeeze it in here.
Thank you so much for taking the time to read this. Please reach out if you run into issues, or have ideas on how to make this setup better. Happy coding!