# How to run uv on Modal


[Modal](https://modal.com/docs) runs Python functions in ephemeral cloud containers, and you define the container image in Python rather than a Dockerfile. That changes how [uv](https://pydevtools.com/handbook/reference/uv.md) fits in: instead of `COPY` and `RUN` lines, Modal's `Image` class exposes `uv_sync()` to build from a [lockfile](https://pydevtools.com/handbook/explanation/what-is-a-lock-file.md) 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:

```python {filename="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:

```bash
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.

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

```python
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:

```python
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:

```python {filename="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-80GB`, `H100`, `H200`, and `B200`:

```python
@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:

```python
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:

```python
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:

```python
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](https://modal.com/docs/guide/images) documents the full `Image` API, including `uv_sync` and `uv_pip_install`.
- [Modal GPU guide](https://modal.com/docs/guide/gpu) lists current GPU types and pricing.
- [How to use uv in a Dockerfile](https://pydevtools.com/handbook/how-to/how-to-use-uv-in-a-dockerfile.md) covers the equivalent `COPY`/`RUN` pattern when you control the Dockerfile instead.
- [How to use a uv lockfile for reproducible Python environments](https://pydevtools.com/handbook/how-to/how-to-use-a-uv-lockfile-for-reproducible-python-environments.md) explains what `uv sync --frozen` guarantees.
