AWS Lambda Packaging with Python and uv: Share Code, Skip the Layers

AWS Lambda Packaging with Python and uv: Share Code, Skip the Layers

16 min read

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.

This is fine — developer surrounded by copy-pasted Lambda code

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 /opt directory. 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/ImportValue locks 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:

  1. Put your shared code in proper Python packages, each with its own pyproject.toml
  2. Reference them as workspace dependencies from each Lambda function’s project
  3. When building for deployment, uv resolves and installs the shared packages just like any other dependency
  4. 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) project

Each 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 microserviceorder-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-customer Lambda 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:

lambda-code/shared-libs/custom-core/pyproject.toml
[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"
lambda-code/shared-libs/custom-core/src/custom_core/date_util.py
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:

lambda-code/order-service/order-models/pyproject.toml
[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"
lambda-code/order-service/order-models/src/order_models/order.py
from pydantic import BaseModel
class Order(BaseModel):
order_id: str
customer_id: str
product_id: str
quantity: int
price: float
created_at: str

This 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:

lambda-code/order-service/create-order/pyproject.toml
[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:

lambda-code/order-service/create-order/src/create_order/lambda_function.py
from order_models.order import Order
from custom_core import date_util
from uuid import uuid4
import random
from 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:

lambda-code/pyproject.toml
[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:

lib/custom-constructs/custom-lambda.construct.ts
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:

  1. uv export --frozen --no-dev --no-editable — Exports a pinned requirements.txt from the function’s pyproject.toml. The --no-editable flag is key: it converts workspace dependencies (like custom-core and order-models) into concrete, installable packages instead of symlinks.
  2. 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.
  3. rsync ... src/ /asset-output/ — Copies the Lambda’s own source code on top, ensuring the handler module is in the right place.
  4. 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:

lib/order-svc.stack.ts
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.

Perfection — self-contained Lambda functions with no layer dependencies

Why This Beats Lambda Layers

Side by side:

Lambda Layersuv Local Packages
IDE support❌ No autocomplete or type checking✅ Full IDE support out of the box
Local testing⚠️ Requires downloading/mocking layersuv sync and run — done
VersioningSequential integers onlyStandard Python semver
Dependency isolationShared /opt dir — potential conflictsSelf-contained per function
Package sizeAll functions share everythingEach function bundles only what it needs
Cold startsBloated by unused layer codeMinimal — no dead weight
Function limit5 layers per functionNo limit
Build complexityLayer publishing + ARN managementuv 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:

  1. Structure your shared code as proper Python packages — cross-service utils in shared-libs, service-level models alongside your functions
  2. Use a uv workspace for version consistency and fast local development
  3. Give each Lambda its own pyproject.toml to declare exactly what it needs (Python has no tree-shaking!)
  4. 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: