AWS Lambda Packaging with Python and uv: Share Code, Skip the Layers
You’ve got multiple Lambda functions. They share models, validation logic, maybe some utility code. You’ve been copy-pasting between functions like it’s 2005, and you know there’s a better way.

The “obvious” answer? “Just use Lambda Layers!” — says every Medium article from 2019.
But here’s the thing: Lambda Layers aren’t a package manager, and using them to share your own application code creates more problems than it solves.
In this post, I’ll show you a cleaner approach: using uv (the blazing-fast Python package manager from Astral) with local packages to share code across multiple Lambda functions — all bundled into self-contained ZIPs by AWS CDK.
The Problem
You’re building a serverless application on AWS with Python. You have multiple microservices — say an order service and a customer service — each with their own Lambda functions. Pretty quickly, you realize that some code needs to be shared:
- Pydantic models for your domain entities
- Utility functions (date formatting, logging helpers)
- Shared exception types
- Common middleware logic
The copy-paste reflex is strong. It works… until it doesn’t. One function has the updated model, two others still have the old version, and you just spent an afternoon debugging why a field is missing in production.
”Just Use Lambda Layers”
Lambda Layers were introduced at re:Invent 2018 as a way to share code and data between functions. Well… not quite.
As Yan Cui puts it:
Lambda Layer is a poor substitute for existing package managers.
And in his earlier article on Lumigo, he outlines several challenges that Layers introduce when used for sharing application code:
- Harder to test locally — Your layer code only exists in the Lambda runtime’s
/optdirectory. Local testing requires extra setup to download and include layers before you can even run your handler. - No semantic versioning — Layers only have sequential integer versions. Was version 7 a breaking change or a patch? Good luck figuring that out without checking the changelog (if there even is one).
- IDE blindness — Your editor doesn’t know about the code in your layers. No autocomplete, no type checking, no “Go to Definition”. You’re basically coding with a blindfold on.
- Layer deletion breaks deployments — If a layer version gets deleted, you can’t update your function until you point it to a new version. Imagine needing a hotfix in production and being blocked by a stale layer reference. Ask me how I know.
- 5 layer limit per function — Sounds generous until you start combining observability tools, shared libs, and extensions.
- Cross-stack deployment is a mess — Sharing a layer across CloudFormation stacks sounds simple until you try to update it.
Export/ImportValuelocks you in, SSM Parameter Store decouples but doesn’t trigger redeployments, and you end up layering workarounds on workarounds.
The full cross-stack layer story
When a layer is shared across multiple CloudFormation stacks, the natural move is to deploy it in a shared base stack and pass the ARN to other stacks. CloudFormation’s Export/ImportValue looks like the right tool, but there’s a catch: once a stack exports a value and another stack imports it, CloudFormation refuses to update or delete that export — meaning a new layer version (which is a new ARN) cannot be propagated without first removing all consumers simultaneously.
The standard workaround is to pass the ARN through SSM Parameter Store instead, which decouples the stacks. But that creates a new problem: CDK and CloudFormation only detect SSM parameter key references at synth time, not value changes at runtime. When your layer publishes a new version and updates the SSM parameter, the consuming stacks have no idea anything changed and won’t pick up the new ARN on the next deploy. You end up needing yet another workaround — a custom resource, a manual parameter bump in the consuming stack, or synth-time SSM resolution — just to propagate a dependency update across stacks.
Lambda Layers do shine as a deployment optimization — reducing bytes to upload when dependencies haven’t changed. And they’re great for distributing large, seldom-changing binaries like FFmpeg. But for sharing your own application code? There’s a much better way.
Enter uv and Local Packages
uv is a fast Python package and project manager from the creators of Ruff. Among its many features, uv has excellent support for workspaces and local path dependencies — which is exactly what we need.
Here’s the gist:
- Put your shared code in proper Python packages, each with its own
pyproject.toml - Reference them as workspace dependencies from each Lambda function’s project
- When building for deployment, uv resolves and installs the shared packages just like any other dependency
- CDK bundles everything into a self-contained ZIP per function
No layers. No magic /opt paths. Just standard Python packaging.
Project Structure
Here’s what the demo repository looks like:
cdk-uv-lambda-packaging/├── lambda-code/ # uv workspace root│ ├── pyproject.toml # workspace definition│ ├── uv.lock # single lockfile for all packages│ ├── shared-libs/│ │ └── custom-core/ # shared utilities (logging, date, etc.)│ │ ├── pyproject.toml│ │ └── src/custom_core/│ ├── order-service/│ │ ├── create-order/ # Lambda function package│ │ │ ├── pyproject.toml│ │ │ └── src/create_order/│ │ └── order-models/ # models shared within this service│ │ ├── pyproject.toml│ │ └── src/order_models/│ └── customer-service/│ ├── get-customer/ # Lambda function package│ │ ├── pyproject.toml│ │ └── src/get_customer/│ └── customer-models/ # models shared within this service│ ├── pyproject.toml│ └── src/customer_models/├── lib/ # CDK infrastructure│ ├── order-svc.stack.ts│ ├── customer-svc.stack.ts│ └── custom-constructs/│ ├── custom-lambda.construct.ts # reusable Lambda+bundling construct│ └── Dockerfile # build image with uv pre-installed└── package.json # CDK (Node/TypeScript) projectEach Lambda ZIP bundles only the packages it needs — nothing shared with the other function:
flowchart LR
subgraph co["create-order.zip"]
direction TB
n1["create_order\n(handler)"]
n2["order_models\n(pydantic models)"]
n3["custom_core\n(shared utils)"]
n4["aws_lambda_powertools\npydantic"]
end
subgraph gc["get-customer.zip"]
direction TB
m1["get_customer\n(handler)"]
m2["customer_models\n(pydantic models)"]
m3["custom_core\n(shared utils)"]
m4["aws_lambda_powertools\npydantic"]
end
classDef order fill:#4a90d9,stroke:#2c6fad,color:#fff
classDef customer fill:#9b59b6,stroke:#7d3c98,color:#fff
classDef shared fill:#e8a838,stroke:#c47d10,color:#fff
classDef external fill:#8e9eab,stroke:#6b7a85,color:#fff
class n1,n2 order
class m1,m2 customer
class n3,m3 shared
class n4,m4 external
The repo is organized by microservice — order-service and customer-service. Each microservice contains its own Lambda functions (e.g., create-order) and its own models package (e.g., order-models). Cross-cutting utilities live in shared-libs/custom-core and are available to all microservices.
Every one of these is a proper Python package with its own pyproject.toml. Each Lambda function declares only the dependencies it actually needs.
Why Each Lambda Gets Its Own pyproject.toml
You might be wondering: “Why not have one pyproject.toml and build all functions from there?”
The answer comes down to a fundamental limitation of the Python ecosystem: there’s no tree-shaking.
In languages like JavaScript or TypeScript, bundlers like esbuild can analyze your import graph and strip out unused code. If your create-order function only imports order_models, the bundler knows to leave customer_models out of the final output. Python doesn’t have that. When you pip install a package, you get the whole thing — every module, every dependency, whether you use it or not.
This matters for Lambda because:
- Deployment package size directly impacts cold start times — more code to load means slower starts
- You’re subject to a 250 MB hard limit (unzipped) on the deployment package
- A single monolithic ZIP for all functions means every function ships every dependency of every other function — your lightweight
get-customerLambda suddenly bundles heavy data processing libraries because some analytics function needed them
By giving each Lambda its own pyproject.toml, you declare exactly what that function needs — no more, no less. Each ZIP is as lean as possible.
Setting Up the Shared Packages
The demo has two layers of shared code:
Cross-Service Shared Library
The custom-core package in shared-libs provides utilities used across all microservices:
[project]name = "custom-core"version = "0.1.0"requires-python = ">=3.14"dependencies = []
[build-system]requires = ["uv_build>=0.8.11,<0.9.0"]build-backend = "uv_build"import datetime as dt
def now_as_iso_str() -> str: return dt.datetime.now(dt.UTC).replace(tzinfo=None).isoformat()Zero external dependencies. Just pure Python utilities that every microservice needs. Things like date formatting, custom exception types, or logging helpers — the kind of code you’d otherwise copy-paste everywhere.
Service-Level Models
Each microservice has its own models package. Here’s the one for the order service:
[project]name = "order-models"version = "0.1.0"requires-python = ">=3.14"dependencies = [ "pydantic>=2.13.1",]
[build-system]requires = ["uv_build>=0.8.11,<0.9.0"]build-backend = "uv_build"from pydantic import BaseModel
class Order(BaseModel): order_id: str customer_id: str product_id: str quantity: int price: float created_at: strThis model lives in its own package so that multiple Lambda functions within the order service can share it. When you add a get-order function tomorrow, it can depend on order-models too — no duplication. Meanwhile, the customer service has its own customer-models package with a Customer model, completely independent.
Your IDE knows about all of these packages. You get type checking. You get autocomplete. You can write unit tests for each one independently. Nothing fancy. That’s the point.
Referencing Shared Packages from a Lambda Function
Each Lambda function has its own pyproject.toml that depends on exactly the packages it needs:
[project]name = "create-order"version = "0.1.0"requires-python = ">=3.14"dependencies = [ "aws-lambda-powertools>=3.28.0", "custom-core", "order-models",]
[build-system]requires = ["uv_build>=0.8.11,<0.9.0"]build-backend = "uv_build"
[tool.uv.sources]order-models = { workspace = true }custom-core = { workspace = true }The workspace = true flag tells uv: “Resolve this package from the workspace, not from PyPI.” During local development, uv links these as editable installs — changes are immediately reflected without reinstalling. When building for Lambda, we’ll use --no-editable to ensure the code is properly copied into the deployment package.
In the handler, you just import everything like any other package:
from order_models.order import Orderfrom custom_core import date_utilfrom uuid import uuid4import randomfrom aws_lambda_powertools import Logger
logger = Logger()
def lambda_handler(event, _): logger.info("Received event: %s", event) order = Order( order_id=str(uuid4()), customer_id="dummy-customer", product_id="dummy-product", quantity=random.randint(1, 5), price=random.uniform(10.0, 100.0), created_at=date_util.now_as_iso_str(), ) return {"statusCode": 200, "body": order.model_dump_json(indent=2)}No sys.path hacks. No /opt/python references. Just normal imports — order_models from the service-level package, custom_core from the shared lib, and aws_lambda_powertools from PyPI. All treated the same way.
The Workspace Root
All of the above is tied together by a workspace pyproject.toml in the lambda-code directory:
[project]name = "cdk-uv-base"version = "0.1.0"requires-python = ">=3.14"dependencies = []
[tool.uv.workspace]members = [ "shared-libs/custom-core", "order-service/create-order", "order-service/order-models", "customer-service/get-customer", "customer-service/customer-models",]This is not what CDK builds from — it ensures version consistency across packages (single uv.lock) and lets you run uv sync once for fast local development. The actual build step still operates per-function.
Bundling for Lambda with CDK
Now for the CDK side. The repo includes a reusable CDK construct — CustomLambdaFunction — that encapsulates the bundling logic so you don’t repeat it for every Lambda:
import { Construct } from 'constructs';import { aws_lambda as lambda, BundlingOutput, DockerImage } from 'aws-cdk-lib';import * as path from 'path';
export interface CustomLambdaFunctionProps { uvPackageDir: string; handler: string; runtime?: lambda.Runtime; architecture?: lambda.Architecture; environment?: Record<string, string>;}
export class CustomLambdaFunction extends Construct { public readonly function: lambda.Function;
constructor(scope: Construct, id: string, props: CustomLambdaFunctionProps) { super(scope, id);
const runtime = props.runtime ?? lambda.Runtime.PYTHON_3_14; const architecture = props.architecture ?? lambda.Architecture.X86_64; const dockerPlatform = architecture === lambda.Architecture.ARM_64 ? 'linux/arm64' : 'linux/amd64'; const uvWorkspacePath = path.join(__dirname, '..', '..', 'lambda-code');
this.function = new lambda.Function(this, 'Function', { runtime, architecture, handler: props.handler, code: lambda.Code.fromAsset(uvWorkspacePath, { bundling: { image: DockerImage.fromBuild(__dirname, { buildArgs: { IMAGE: runtime.bundlingImage.image }, platform: dockerPlatform, }), platform: dockerPlatform, command: [ 'bash', '-c', [ // 1. Export all deps (workspace + external) to requirements.txt `uv export -q --frozen --no-dev --no-editable ` + `--directory ${props.uvPackageDir} -o requirements.txt`, // 2. Install all deps — workspace packages get built as wheels `uv pip install --no-installer-metadata --no-compile-bytecode ` + `--no-deps -q -r ${props.uvPackageDir}/requirements.txt ` + `--target /asset-output`, // 3. Copy the lambda's own source code (overwrites pip-installed version) `rsync -rLq --exclude='*.pyc' --exclude='*.md' --exclude='*.toml' ` + `--exclude='requirements.txt' ${props.uvPackageDir}/src/ /asset-output/`, // 4. Clean up `rm -f ${props.uvPackageDir}/requirements.txt`, ].join(' && '), ], user: 'root', outputType: BundlingOutput.NOT_ARCHIVED, }, }), environment: props.environment, }); }}Breaking down the bundling command:
uv export --frozen --no-dev --no-editable— Exports a pinnedrequirements.txtfrom the function’spyproject.toml. The--no-editableflag is key: it converts workspace dependencies (likecustom-coreandorder-models) into concrete, installable packages instead of symlinks.uv pip install --target /asset-output— Installs everything into the CDK asset output directory. Workspace packages get automatically built as wheels and installed alongside PyPI dependencies.rsync ... src/ /asset-output/— Copies the Lambda’s own source code on top, ensuring the handler module is in the right place.- Clean up — Removes the temporary
requirements.txt.
One detail worth calling out: the entire bundling step runs inside a Docker container built from the Lambda runtime’s own base image. That matters more than it might seem.
Lambda runs on Amazon Linux. Python packages with native C extensions — psycopg for PostgreSQL, cryptography, Pillow, numpy — need to be compiled for the right OS and CPU architecture. If you pip install them on macOS, you get macOS binaries. Deploy those to Lambda and you’ll be greeted with a cryptic Runtime.ImportModuleError at 2am. Running the install step inside the container means your compiled packages target Amazon Linux from the start — no cross-compilation gymnastics, no “works on my machine” surprises.
Why not use @aws-cdk/aws-lambda-python-alpha?
This bundling logic was adapted from the official CDK Python alpha package. The reason I couldn’t use it directly: it expects the pyproject.toml to sit at the root of the Docker bundling context — the directory CDK mounts into the container. In a uv workspace, each function’s pyproject.toml is nested several directories deep. When the alpha construct tries to resolve local workspace dependencies from the wrong root, the build fails. The custom construct here passes the function-specific subdirectory explicitly so uv can find everything it needs.
Here’s the full build flow at a glance:
flowchart TD
A["pyproject.toml\n(per Lambda function)"]
A -->|"uv export --frozen --no-editable"| B["requirements.txt\n(workspace pkgs as wheels + PyPI pins)"]
B -->|"uv pip install --target /asset-output"| C["/asset-output\n(all dependencies)"]
D["src/ handler code"] -->|"rsync"| C
C -->|"CDK zips"| E["Lambda ZIP\n(self-contained)"]
The result? Each Lambda function’s ZIP contains everything it needs: handler code, service-level models, shared utilities, and all external dependencies. Completely self-contained.
Using the construct from a CDK stack:
import { CustomLambdaFunction } from './custom-constructs/custom-lambda.construct';
new CustomLambdaFunction(this, 'CreateOrder', { uvPackageDir: 'order-service/create-order', handler: 'create_order.lambda_function.lambda_handler',});Two lines of config. No layer ARN management. No publishing step. Just point CDK at the function’s package directory and let uv handle the rest.

Why This Beats Lambda Layers
Side by side:
| Lambda Layers | uv Local Packages | |
|---|---|---|
| IDE support | ❌ No autocomplete or type checking | ✅ Full IDE support out of the box |
| Local testing | ⚠️ Requires downloading/mocking layers | ✅ uv sync and run — done |
| Versioning | Sequential integers only | Standard Python semver |
| Dependency isolation | Shared /opt dir — potential conflicts | Self-contained per function |
| Package size | All functions share everything | Each function bundles only what it needs |
| Cold starts | Bloated by unused layer code | Minimal — no dead weight |
| Function limit | 5 layers per function | No limit |
| Build complexity | Layer publishing + ARN management | uv export + pip install |
The real upside: your shared code behaves like any other Python dependency. You develop with it, test with it, and deploy with it — using the same standard Python tools you already know. No AWS-specific packaging ceremony required.
When Would You Still Use Layers?
That said, Lambda Layers still have legitimate use cases:
- Large binary dependencies like FFmpeg or Pandoc that rarely change and aren’t distributed via pip
- Custom Lambda runtimes where layers are the intended distribution mechanism
- Deployment optimization — tools like
serverless-layers(now deprecated) used layers under the hood to cache unchanged dependencies, speeding up redeploys without you managing the layer lifecycle manually
For everything else — especially sharing your own application code — local packages with uv are simpler, safer, and more developer-friendly.
Scaling Up: When a Monorepo Isn't Enough
The uv workspace approach works great while all your teams share the same repository. Once shared packages need to be consumed across separate repos — or you’re in an enterprise where packages go through an audit and approval pipeline — local path dependencies stop being practical.
At that point, the natural next step is publishing to a registry:
- Public / open source projects: Publish to PyPI. Any pip-compatible tool (uv included) pulls them down automatically. Good for utilities you’re happy to make public.
- Enterprise / private: Options like Nexus Repository, JFrog Artifactory, or AWS CodeArtifact give you a private PyPI-compatible endpoint with access control, dependency auditing, and upstream caching of public packages.
With a registry in place, the workspace = true references in your pyproject.toml swap out for standard versioned package names. The bundling step stays exactly the same — uv resolves and installs them just like any other dependency.
Wrapping Up
The serverless ecosystem has matured, and so have Python’s packaging tools. Lambda Layers solved a real problem back in 2018, but for sharing your own code between Python Lambda functions in 2026, there’s a much cleaner path:
- Structure your shared code as proper Python packages — cross-service utils in
shared-libs, service-level models alongside your functions - Use a uv workspace for version consistency and fast local development
- Give each Lambda its own
pyproject.tomlto declare exactly what it needs (Python has no tree-shaking!) - Let a reusable CDK construct bundle self-contained ZIPs with
uv export --no-editable
No layers to publish. No ARN juggling. No broken deploys because someone deleted a layer version. No bloated ZIPs with dead-weight dependencies slowing your cold starts. Just standard Python packaging — done fast with uv.
Check out the full demo repository on GitHub to see it all wired up end-to-end.
Further reading:
- Lambda Layers: When to use it — Yan Cui (Lumigo)
- Lambda layer: not a package manager, but a deployment optimization — Yan Cui (The Burning Monk)
- uv Documentation