Python Lambda with uv
Deploy Python Lambda functions using uv for dependency management. The module builds a separate dependency layer via cross-compilation, packages your source code, and provisions the Lambda function with IAM, CloudWatch, and optional features like durable execution, DLQ, and versioned aliases.
How it works
- Runs
uv lock - Runs
uv exportto resolve pinned dependencies frompyproject.toml+uv.lock - Cross-compiles them with
uv pip install --only-binary=:all:for the target Lambda platform (arm64/x86_64) - Packages the result into a
deps.zipLambda layer - Zips the source directory into
code.zip(excludingtests/and__pycache__/) - Creates the Lambda function, layer, IAM role, and CloudWatch log group
The layer rebuilds only when pyproject.toml or uv.lock changes. The code zip rebuilds whenever source files change.
Folder structure
Multiple Lambda functions share a single pyproject.toml and uv.lock. Each function lives in its own subdirectory, and its runtime dependencies are declared as a named dependency group.
lambdas/
├── pyproject.toml # Shared — declares dependency groups
├── uv.lock # Shared — pinned dependencies
├── my-api-handler/ # Lambda A — flat module
│ ├── handler.py
│ ├── settings.py
│ └── tests/
├── my-worker/ # Lambda B — Python package
│ └── my_worker/
│ ├── __init__.py
│ ├── handler.py
│ ├── clients.py
│ └── settings.py
Dependency groups
Each Lambda gets its own dependency group containing only its runtime packages. Shared tooling (testing, linting) goes in dev. AWS Lambda Powertools is excluded from the layer automatically since it's provided as a managed AWS layer.
[project]
name = "my-lambdas"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
my-api-handler = [
"pydantic-settings>=2.13.1",
"boto3>=1.41.1",
]
my-worker = [
"pydantic-settings>=2.13.1",
"httpx>=0.28.0",
]
dev = [
"aws-lambda-powertools>=3.23.0",
"boto3>=1.41.1",
"moto[s3,sns]>=5.0.0",
"pytest>=9.0.1",
"ruff>=0.14.6",
]
[tool.uv]
default-groups = ["dev", "my-api-handler", "my-worker"]
- Group names match the
dependency_groupvariable passed to the module aws-lambda-powertoolsbelongs indevonly — the build script strips it from the layer since it comes from the managed AWS layer ARNdefault-groupsensuresuv syncinstalls everything for local development
Usage
Basic — with a dependency group
module "my_api" {
source = "path/to/modules/python_uv_lambda_zip"
function_name = "my-api-handler"
source_dir = "${path.module}/lambdas/my-api-handler"
lambdas_dir = "${path.module}/lambdas"
dependency_group = "my-api-handler"
handler = "handler.handler"
description = "Handles API requests"
powertools_layer_arn = var.powertools_layer_arn
timeout = 30
environment_variables = {
TABLE_NAME = aws_dynamodb_table.main.name
LOG_LEVEL = "INFO"
}
}
# Attach extra IAM policies to the module's role
resource "aws_iam_role_policy" "my_api" {
name = "${module.my_api.function_name}-policy"
role = module.my_api.role_id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:PutItem"]
Resource = aws_dynamodb_table.main.arn
}
]
})
}
Python package handler
When the Lambda source is a proper Python package (directory with __init__.py), set the handler to <package>.handler.handler:
module "my_worker" {
source = "path/to/modules/python_uv_lambda_zip"
function_name = "my-worker"
source_dir = "${path.module}/lambdas/my-worker"
lambdas_dir = "${path.module}/lambdas"
dependency_group = "my-worker"
handler = "my_worker.handler.handler"
powertools_layer_arn = var.powertools_layer_arn
memory_size = 256
}
Without dependency groups
If the pyproject.toml uses top-level dependencies instead of groups, omit dependency_group:
module "simple" {
source = "path/to/modules/python_uv_lambda_zip"
function_name = "my-simple-function"
source_dir = "${path.module}/lambdas/my-function"
lambdas_dir = "${path.module}/lambdas"
powertools_layer_arn = var.powertools_layer_arn
}
Terragrunt symlink trick
When using Terragrunt, plan/apply runs inside a temporary .terragrunt-cache/ directory. Relative references to lambdas/ break because ${path.module} resolves to the cache path.
Fix this with a before_hook that symlinks the real lambdas/ directory into the working directory:
# terragrunt.hcl
terraform {
source = "."
before_hook "symlink_lambdas" {
commands = ["init", "plan", "apply", "destroy"]
execute = ["bash", "-c", "ln -sfn ${values.lambdas_dir} lambdas"]
}
}
The stack file passes the absolute path:
# terragrunt.stack.hcl
locals {
root_dir = dirname(find_in_parent_folders("root.hcl"))
}
unit "my_lambda" {
source = "../../units/my_lambda"
path = "my_lambda"
values = {
source_dir = "${local.root_dir}/lambdas/my-api-handler"
lambdas_dir = "${local.root_dir}/lambdas"
}
}
This ensures uv export runs where pyproject.toml lives, output paths are stable, and file hashes stay consistent across machines and CI.
Inputs
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| additional_layers | Extra Lambda layer ARNs to attach to the function | list(string) |
[] |
no |
| alias_name | Alias name for the published function (only used when publishing) | string |
"prod" |
no |
| architecture | CPU architecture for the Lambda function | string |
"arm64" |
no |
| dependency_group | uv dependency group name to export; null exports the project's top-level dependencies | string |
null |
no |
| description | Lambda function description | string |
"" |
no |
| durable_config | Durable execution configuration (only used when enable_durable_execution is true) | object({ |
{ |
no |
| enable_dlq | Whether to create a dead letter queue for failed invocations | bool |
false |
no |
| enable_durable_execution | Enable durable execution (checkpoint/replay) for the Lambda function | bool |
false |
no |
| environment_variables | Environment variables for the Lambda function | map(string) |
{} |
no |
| function_name | Lambda function name | string |
n/a | yes |
| handler | Lambda handler entrypoint | string |
"handler.handler" |
no |
| lambdas_dir | Absolute path to the parent directory containing pyproject.toml, uv.lock, and the build scripts' working directory | string |
n/a | yes |
| log_retention_days | CloudWatch log group retention in days | number |
365 |
no |
| memory_size | Amount of memory in MB for the Lambda function | number |
128 |
no |
| powertools_layer_arn | ARN of the AWS Lambda Powertools for Python layer | string |
n/a | yes |
| publish | Whether to publish a versioned Lambda function | bool |
false |
no |
| python_version | Python version for the Lambda runtime | string |
"3.14" |
no |
| role_name | IAM role name; defaults to " |
string |
null |
no |
| source_dir | Absolute path to the Lambda's Python source directory | string |
n/a | yes |
| tags | Tags applied to all resources created by this module | map(string) |
{} |
no |
| timeout | Lambda function timeout in seconds | number |
30 |
no |
| tracing_mode | X-Ray tracing mode for the Lambda function | string |
"PassThrough" |
no |
Outputs
| Name | Description |
|---|---|
| alias_arn | ARN of the Lambda alias (null when not publishing) |
| alias_invoke_arn | Invoke ARN of the Lambda alias (null when not publishing) |
| dlq_arn | ARN of the dead letter queue (null when DLQ is disabled) |
| dlq_url | URL of the dead letter queue (null when DLQ is disabled) |
| function_arn | ARN of the Lambda function |
| function_name | Name of the Lambda function |
| invoke_arn | Invoke ARN of the Lambda function |
| layer_arn | ARN of the dependency Lambda layer |
| layer_version | Version number of the dependency Lambda layer |
| log_group_arn | ARN of the CloudWatch log group |
| log_group_name | Name of the CloudWatch log group |
| role_arn | ARN of the IAM role |
| role_id | ID of the IAM role |
| role_name | Name of the IAM role |