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:
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.pyModal 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.
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:
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-80GB, H100, H200, 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 what installs with groups and extras, 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.10.9")Why does a package query the GPU during the build?
Some packages read the GPU at install time to set compilation flags, which fails on a CPU-only builder. Pass gpu= to the image step itself, not just the function, so the build runs on a GPU container:
image = modal.Image.debian_slim().uv_pip_install("flash-attn", gpu="T4")Both uv_sync() and uv_pip_install() accept gpu=. Use it only for packages that genuinely probe the device during installation; a plain torch install does not need it.
Learn more
- Modal Images guide documents the full
ImageAPI, includinguv_syncanduv_pip_install. - Modal GPU guide lists current GPU types and pricing.
- How to use uv in a Dockerfile covers the equivalent
COPY/RUNpattern when you control the Dockerfile instead. - How to use a uv lockfile for reproducible Python environments explains what
uv sync --frozenguarantees.