Skip to content

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

  1. Runs uv lock
  2. Runs uv export to resolve pinned dependencies from pyproject.toml + uv.lock
  3. Cross-compiles them with uv pip install --only-binary=:all: for the target Lambda platform (arm64/x86_64)
  4. Packages the result into a deps.zip Lambda layer
  5. Zips the source directory into code.zip (excluding tests/ and __pycache__/)
  6. 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_group variable passed to the module
  • aws-lambda-powertools belongs in dev only — the build script strips it from the layer since it comes from the managed AWS layer ARN
  • default-groups ensures uv sync installs 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
}

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({
execution_timeout = number
retention_period = number
})
{
"execution_timeout": 86400,
"retention_period": 7
}
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 "-role" when not provided 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