Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A collection of best-practice guides for coding in Python and Rust, maintained b
|-------|-------------|
| [Python Guidelines](python/best-practices.md) | Common guidelines for writing clean, idiomatic Python |
| [Python Logging](docs/logging.md) | Logging configuration, log levels, and structured logging |
| [uv.lock Guide](docs/uv-lock.md) | Lock file best practices: when to commit, how to update, CI/CD usage |
| Rust Guidelines *(coming soon)* | Best practices for safe, performant Rust code |

## Contributing
Expand Down
305 changes: 305 additions & 0 deletions docs/uv-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
# uv.lock Best Practices

A concise guide to understanding and managing the `uv.lock` file in Python projects.

---

## Table of Contents

1. [What is `uv.lock`?](#1-what-is-uvlock)
2. [Why Lock Files Matter](#2-why-lock-files-matter)
3. [When to Commit `uv.lock`](#3-when-to-commit-uvlock)
4. [`uv lock` vs `uv sync`](#4-uv-lock-vs-uv-sync)
5. [Comparison with pip-tools and Poetry](#5-comparison-with-pip-tools-and-poetry)
6. [Updating Dependencies](#6-updating-dependencies)
7. [CI/CD Considerations](#7-cicd-considerations)

---

## 1. What is `uv.lock`?

`uv.lock` is the lock file generated by [uv](https://github.com/astral-sh/uv). It records the exact resolved versions — including transitive dependencies — of every package in your project's dependency graph.

`pyproject.toml` expresses *constraints* ("I need `httpx` version 0.27 or later"). `uv.lock` records the *resolution* ("the full graph was solved and `httpx 0.28.1` was chosen, along with its exact transitive dependencies `httpcore 1.0.5`, `certifi 2024.12.14`, …"). Together they give you both flexibility and reproducibility.

Key properties of `uv.lock`:

- **Cross-platform.** A single `uv.lock` encodes wheels for all supported platforms and Python versions, so the same file works on macOS, Linux, and Windows.
- **Machine-managed.** The file is generated and updated by `uv` — never edit it by hand.
- **Human-readable.** It uses a TOML-like format you can diff and review in pull requests.
- **Comprehensive.** Records every package in the full dependency graph, not just your direct dependencies.

```
# uv.lock (excerpt — never edit manually)
version = 1
requires-python = ">=3.11"

[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
```

---

## 2. Why Lock Files Matter

Without a lock file, `pip install -r requirements.txt` (or `uv sync` without a lock) re-resolves the dependency graph on every install. That means:

- A new version of a transitive dependency released overnight can silently change your environment.
- Two developers cloning the same repo at different times may end up with different packages installed.
- A CI run on Tuesday may pass while a deploy on Wednesday fails — with no code changes in between.

A lock file eliminates this class of problem by recording the exact versions that were tested and known to work:

| Without lock file | With lock file |
|-------------------|----------------|
| Resolution happens at install time | Resolution happens once (`uv lock`), result is committed |
| Transitive deps float to latest | Every transitive dep is pinned exactly |
| Installs may differ across machines | All machines install the same graph |
| Bugs from unexpected upgrades are hard to trace | Upgrades are explicit, reviewable, and intentional |

> **Rule of thumb:** A lock file is a contract between your code and its dependencies. Commit it, review changes to it, and update it deliberately.

---

## 3. When to Commit `uv.lock`

### Applications — always commit

An *application* is anything you deploy or distribute as a runnable artefact: a web service, a CLI tool, a data pipeline, a script. For applications, commit `uv.lock` unconditionally.

- Deployments are reproducible: the exact same packages go to staging and production.
- Developers, CI, and production all share one resolved graph.
- Dependency changes are visible in code review — a reviewer can see when a transitive package was silently upgraded.

```bash
# For applications: always checked in
git add uv.lock
git commit -m "chore: update uv.lock"
```

### Libraries — usually do not commit

A *library* is a package you publish to PyPI for others to depend on. For libraries, committing `uv.lock` is typically not useful and can cause confusion:

- The lock file only affects your development environment; it is ignored by anyone who installs your library as a dependency.
- If you commit it, you may mislead contributors into thinking a specific resolution is "required" rather than just "what was convenient when you last ran `uv lock`".
- Lock files for libraries can become stale quickly and add noise to pull requests.

The recommended approach for libraries is to commit `pyproject.toml` with loose version constraints and add `uv.lock` to `.gitignore`:

```gitignore
# .gitignore for a library
uv.lock
```

**Exception:** If your library repository also contains integration tests, example applications, or a development environment that benefits from reproducibility, you may commit `uv.lock` as a *development convenience* — just make clear in your README that it is not meaningful for end-users.

---

## 4. `uv lock` vs `uv sync`

These two commands are often confused but serve distinct purposes:

| Command | What it does |
|---------|-------------|
| `uv lock` | Resolves the dependency graph and **writes** (or updates) `uv.lock`. Does **not** install anything. |
| `uv sync` | Reads `uv.lock` and **installs** the exact pinned packages into the virtual environment. |

```bash
# Resolve and record — run when you change pyproject.toml
uv lock

# Install from the existing lock file — run to set up or refresh your environment
uv sync

# Both at once: re-resolve AND install (common during development)
uv sync # uv sync always re-locks if pyproject.toml changed
```

**When to run each:**

- Run `uv lock` after editing `pyproject.toml` (adding, removing, or changing dependency constraints) to update the lock file before committing.
- Run `uv sync` when setting up a fresh clone, after pulling changes, or in CI to install from the committed lock file.
- Use `uv sync --frozen` in CI or production deployments to install exactly what is in the committed lock file and fail if `pyproject.toml` and `uv.lock` are out of sync (see [CI/CD Considerations](#7-cicd-considerations)).

---

## 5. Comparison with pip-tools and Poetry

If you are coming from another tool, here is how `uv.lock` maps to what you already know:

| Feature | pip-tools + `requirements.txt` | Poetry + `poetry.lock` | uv + `uv.lock` |
|---------|-------------------------------|------------------------|----------------|
| Lock format | Plain text `requirements.txt` | TOML (`poetry.lock`) | TOML-like (`uv.lock`) |
| Cross-platform single file | No — one file per platform/extras | Yes | Yes |
| Transitive deps pinned | Yes (compiled output) | Yes | Yes |
| Dev / optional groups | Manual (separate files) | Dependency groups | Dependency groups |
| Speed | Slow (pure Python) | Moderate | Very fast (Rust) |
| Editable installs | Requires `pip install -e` | Built-in | Built-in |
| `pyproject.toml` native | Partial (`pip-compile` reads it) | Yes | Yes |

**Migrating from pip-tools:**

```bash
# Before (pip-tools)
pip-compile pyproject.toml -o requirements.txt
pip install -r requirements.txt

# After (uv)
uv lock # generates uv.lock
uv sync # installs from uv.lock
```

**Migrating from Poetry:**

```bash
# Before (Poetry)
poetry lock
poetry install

# After (uv)
uv lock
uv sync
```

If you have an existing `requirements.txt` you want to preserve for legacy tooling, `uv` can still export to that format:

```bash
uv export --format requirements-txt > requirements.txt
```

---

## 6. Updating Dependencies

The lock file should be updated deliberately, not silently. `uv` gives you fine-grained control over what gets updated.

### Upgrade all packages

```bash
uv lock --upgrade
uv sync
```

This re-resolves the entire dependency graph against the latest available versions that still satisfy your `pyproject.toml` constraints, then updates `uv.lock` with the result.

### Upgrade a single package

```bash
uv lock --upgrade-package httpx
uv sync
```

Only the specified package (and its transitive dependencies, if necessary) is upgraded. Everything else in the lock file stays pinned at its current version. This is the safest way to take a targeted security patch.

### Add or remove a dependency

```bash
# Adding a new dependency updates pyproject.toml AND uv.lock
uv add requests

# Removing a dependency updates pyproject.toml AND uv.lock
uv remove requests
```

`uv add` and `uv remove` always keep `uv.lock` in sync automatically — no need to run `uv lock` separately afterwards.

### Review what changed

After any update, review the diff before committing:

```bash
git diff uv.lock
```

Look for unexpected upgrades to transitive dependencies. A surprising change to a deep transitive package is worth investigating before merging.

---

## 7. CI/CD Considerations

### Enforce the lock file with `--frozen`

In CI and production deployments, always install with `--frozen` to ensure the environment exactly matches the committed lock file and to catch any accidental drift:

```bash
uv sync --frozen
```

`--frozen` fails with an error if `pyproject.toml` and `uv.lock` are out of sync (e.g., a developer added a dependency to `pyproject.toml` but forgot to commit the updated `uv.lock`). This turns a silent "works differently in CI" bug into an immediate, obvious failure.

### Example GitHub Actions workflow

```yaml
name: CI

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
run: uv python install

- name: Install dependencies (frozen)
run: uv sync --frozen --all-extras

- name: Run tests
run: uv run pytest tests/ -v
```

### Cache the virtual environment

Caching `.venv` between CI runs significantly reduces install time, especially for large dependency graphs:

```yaml
- name: Cache virtual environment
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
venv-${{ runner.os }}-

- name: Install dependencies (frozen)
run: uv sync --frozen --all-extras
```

The cache key includes `uv.lock`'s hash, so the cache is automatically invalidated whenever the lock file changes.

### Verify the lock file is up to date in CI

Add a check to catch PRs where `pyproject.toml` was modified but `uv.lock` was not updated:

```yaml
- name: Check lock file is up to date
run: uv lock --check
```

`uv lock --check` exits with a non-zero status if the current `uv.lock` does not reflect the current `pyproject.toml` constraints, without modifying any files.

---

## Further Reading

- [uv documentation — Lock files](https://docs.astral.sh/uv/concepts/projects/sync/)
- [uv documentation — `uv lock`](https://docs.astral.sh/uv/reference/cli/#uv-lock)
- [uv documentation — `uv sync`](https://docs.astral.sh/uv/reference/cli/#uv-sync)
- [astral-sh/setup-uv — GitHub Actions integration](https://github.com/astral-sh/setup-uv)
- [pip-tools documentation](https://pip-tools.readthedocs.io/)
- [Poetry documentation — Lock file](https://python-poetry.org/docs/basic-usage/#installing-with-poetrylock)