Skip to content

How to run uv on Modal

Modal runs Python functions in ephemeral cloud containers, and you define the container image in Python rather than a Dockerfile. That changes how uv fits in: instead of COPY and RUN lines, Modal’s Image class exposes uv_sync() to build from a lockfile and uv_pip_install() for one-off installs. Both run uv inside the build, and the result is reachable from a GPU function with one decorator argument.

Build a Modal image from your uv.lock

uv_sync() is the reproducible path. It adds your pyproject.toml and uv.lock to the build context and runs uv sync --frozen, installing the exact versions the lockfile pins:

train.py
import modal

image = modal.Image.debian_slim(python_version="3.12").uv_sync()

app = modal.App("uv-on-modal", image=image)


@app.function()
def show_versions():
    import numpy as np
    import torch

    print("torch", torch.__version__, "numpy", np.__version__)


@app.local_entrypoint()
def main():
    show_versions.remote()

Add Modal to your project and run the script from the project root, where pyproject.toml and uv.lock live:

uv add --dev modal
uv run modal run train.py

Modal expands uv_sync() into a multi-step build: it copies the uv binary from ghcr.io/astral-sh/uv, copies pyproject.toml and uv.lock, and runs uv sync --frozen --compile-bytecode. Because the sync is frozen, a build against an out-of-date lockfile fails instead of resolving new versions. Run uv lock and commit before deploying.

uv_sync() installs your third-party dependencies but not the project package itself (it runs the equivalent of uv sync --no-install-project). The examples here import only third-party packages, so they work as written. When a function imports your own code, add it after the sync with image.add_local_python_source("your_package").

The default uv_project_dir is the current working directory. Point it at a subdirectory when your Modal entrypoint sits outside the project:

image = modal.Image.debian_slim().uv_sync(uv_project_dir="services/worker")

Install a few packages without a lockfile

When you don’t need full project reproducibility, uv_pip_install() takes packages directly, like pip install but resolved by uv at build time:

image = modal.Image.debian_slim().uv_pip_install("torch==2.7.1", "numpy")

Pin every version. uv_pip_install("torch") resolves to the newest release whenever the image rebuilds, so an unpinned spec produces a different environment over time. Reach for uv_sync() instead once the dependency list grows past a couple of packages or you want the image to match local development exactly.

Run the synced environment on a GPU

Attach a GPU by passing gpu= to the function decorator. The packages installed by uv_sync() or uv_pip_install() are importable inside the function, so a CUDA build of torch finds the device:

train.py
import modal

image = modal.Image.debian_slim(python_version="3.12").uv_sync()

app = modal.App("uv-on-modal", image=image)


@app.function(gpu="T4")
def train():
    import torch

    print("cuda available:", torch.cuda.is_available())
    print("device:", torch.cuda.get_device_name(0))
    x = torch.randn(4096, 4096, device="cuda")
    print("matmul norm:", (x @ x).norm().item())


@app.local_entrypoint()
def main():
    train.remote()

Running this prints cuda available: True on a Tesla T4. The default PyPI torch wheel that uv resolves for Linux bundles its own CUDA runtime, so no system CUDA install is needed on the image.

Request more than one GPU by appending a count. Valid types include T4, L4, A10, L40S, A100, A100-40GB, A100-80GB, RTX-PRO-6000, H100, H100!, H200, B200, and B200+:

@app.function(gpu="H100:8")
def train_large():
    ...

Control which dependency groups install

uv_sync() mirrors uv sync flags through keyword arguments. Limit or extend which dependency groups and extras install, and override the frozen default when you deliberately want a fresh resolve:

image = modal.Image.debian_slim().uv_sync(
    groups=["ml"],        # install the optional `ml` dependency group
    extras=["cuda"],      # install the `cuda` extra
    frozen=False,         # allow uv to re-resolve at build time
)

Pin uv itself with uv_version so a build months from now uses the same resolver:

image = modal.Image.debian_slim().uv_sync(uv_version="0.11.26")

Why does a package query the GPU during the build?

Some packages read the device at install time to set compilation flags, which fails on a CPU-only builder. DeepSpeed is one: its op builder calls torch.cuda.get_device_properties(0).major and derives TORCH_CUDA_ARCH_LIST from the card it finds. Pass gpu= to the image step itself, not just the function, so the build runs on a GPU container:

image = modal.Image.from_registry(
    "nvidia/cuda:12.8.0-devel-ubuntu22.04", add_python="3.12"
).uv_pip_install("deepspeed", gpu="T4")

The base image matters as much as gpu=. Packages that compile CUDA extensions need the toolkit, which debian_slim does not carry, so a slim builder stops at OSError: CUDA_HOME environment variable is not set before it reaches the device.

Both uv_sync() and uv_pip_install() accept gpu=. Reach for it only when a package probes the device during installation. A plain torch install does not, and flash-attn reads an environment variable rather than the device, so its prebuilt wheels skip the build entirely.

Learn more

Last updated on