A Python application may work perfectly on a developer's laptop but fail in CI or production because a dependency has changed. A package may have released a new version, a transitive dependency may have been updated, or two environments may have installed different versions of the same library.

These problems are difficult to troubleshoot when a project does not record exactly which dependencies were resolved.

The Python tool uv helps manage dependencies, virtual environments, and project workflows. One of its useful features is dependency locking, which records resolved package versions and other information needed to reproduce an environment.

In this article, you will learn how to use uv to manage a Python project, generate and maintain a lockfile, install locked dependencies in CI, and avoid common mistakes that make builds less reproducible.

Why Python Projects Need a Lockfile

Python projects often declare dependencies using version constraints.

For example, a project's pyproject.toml might contain:

[project]
name = "order-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.110",
    "httpx>=0.27"
]

These constraints specify acceptable package versions, but they do not necessarily identify one exact version for every dependency.

If you install the project on different days, the resolver may select different compatible versions as new releases become available.

That can lead to differences in behavior, unexpected bugs, or test failures.

A lockfile records the resolved dependency set, including transitive dependencies. It helps the project use the same selected versions across development machines and automated builds.

However, a lockfile does not make every part of an environment identical. Python versions, operating systems, native libraries, environment variables, external services, and package installation conditions can still affect behavior.

The goal is to make dependency resolution predictable and repeatable.

What Is uv?

uv is a Python tool for managing packages, project dependencies, virtual environments, and Python versions.

It provides a unified workflow for tasks that might otherwise require several separate commands and tools.

For dependency management, its main features include:

A typical project uses pyproject.toml to describe the dependencies it needs and uv.lock to record the resolved dependency versions.

Step 1: Create a Python Project with uv

Start by installing uv using an installation method approved for your development environment.

Then create a new project:

uv init order-service
cd order-service

This creates a basic project structure, including a pyproject.toml file.

The exact generated files depend on the project type and the installed version of uv.

Open pyproject.toml and review the project metadata and Python version requirements.

For an existing project, you do not need to create a new project from scratch. You can introduce uv into an existing repository by configuring the project metadata and managing dependencies through the tool.

Before adopting it in a team, agree on the supported Python version and the commands developers should use.

Step 2: Add Dependencies

Use uv add to add a runtime dependency.

For example:

uv add fastapi httpx

This command updates the project's dependency declarations and resolves the required packages.

You can also specify a version constraint:

uv add "fastapi>=0.110,<1.0"

The constraint defines the range of acceptable versions. The lockfile records the selected version from that range.

For development tools, use a development dependency group:

uv add --dev pytest ruff

This separates development dependencies from the project's runtime requirements.

Keeping runtime and development dependencies separate makes it easier to build a production environment without installing tools that are needed only for testing or code quality checks.

Step 3: Generate and Maintain uv.lock

When you add dependencies to a project, uv resolves them and creates or updates uv.lock.

You can explicitly update the lockfile with:

uv lock

The lockfile records the resolved package versions and relevant distribution information for the dependency graph.

For example, the project may allow a range of HTTP client versions in pyproject.toml, while uv.lock records one selected version and the resolved versions of its dependencies.

The two files serve different purposes:

FilePurpose
pyproject.tomlDeclares project metadata, Python requirements, and dependency constraints
uv.lockRecords the resolved dependency versions for reproducible installation

Both files should normally be committed to version control for an application project.

If a developer changes a dependency declaration, the lockfile should be updated and reviewed along with that change.

Do not manually edit uv.lock to change package versions. Use uv commands so that the dependency graph remains consistent.

Step 4: Install Dependencies from the Lockfile

After dependencies are declared and locked, developers can synchronize the project environment:

uv sync

This creates or updates the project's virtual environment and installs the dependencies required by the project configuration.

For a CI build that must use the existing lockfile without modifying it, use:

uv sync --locked

The --locked option requires the lockfile to be up to date with the project metadata. If the dependency declarations and lockfile are inconsistent, the command fails rather than silently updating the lockfile.

This is useful in CI because it detects a missing or outdated lockfile during the build.

For a production environment that should install only runtime dependencies, you can use:

uv sync --locked --no-dev

This excludes the development dependency group.

Check the project's dependency-group configuration if it uses additional groups or custom installation settings.

Step 5: Run Tests with the Locked Environment

After synchronizing dependencies, run the project's tests.

For example:

uv run --locked pytest

This runs pytest within the project's managed environment and requires the lockfile to be consistent with the project configuration.

For code quality checks, you can run:

uv run --locked ruff check .

If ruff is declared as a development dependency, it will be available after the appropriate development dependencies have been synchronized.

Using the same locked environment for local testing and CI reduces differences caused by dependency resolution.

It does not replace testing across supported Python versions or operating systems. If the application supports several environments, test each environment that matters to your deployment.

Step 6: Use uv in GitHub Actions

You can add dependency locking to a GitHub Actions workflow.

The following example installs uv, synchronizes the locked environment, and runs tests:

name: Python CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Synchronize locked dependencies
        run: uv sync --locked

      - name: Run tests
        run: uv run --locked pytest

This workflow assumes that the project declares its test dependencies and that pytest is available in the synchronized environment.

For a production pipeline, verify the current versions and interfaces of the actions you use. Security-sensitive workflows should pin third-party actions to reviewed commit SHAs rather than relying only on mutable version tags.

The workflow performs several important checks:

  1. It checks out the source code.
  2. It selects the intended Python version.
  3. It installs uv.
  4. It synchronizes dependencies without changing the lockfile.
  5. It runs tests using the locked environment.

If pyproject.toml changes without a corresponding lockfile update, the synchronization step fails. This prevents the build from silently resolving a different dependency set.

Step 7: Cache Dependencies in CI

Locking dependencies makes installation predictable, but package downloads can still take time.

CI caching can reduce repeated downloads. For example, GitHub Actions can cache files associated with uv's cache directory.

A simple approach uses the setup-uv action's cache support:

- name: Install uv
  uses: astral-sh/setup-uv@v6
  with:
    enable-cache: true
    cache-dependency-glob: uv.lock

This example enables caching and uses uv.lock as a dependency-related cache input.

Confirm the current action documentation and supported inputs before using this configuration in production. Cache behavior depends on the action version and the environment.

Caching is a performance optimization, not a replacement for the lockfile. If the cache is empty or unavailable, the project should still install the same resolved dependency versions.

Also avoid caching the entire virtual environment without understanding how Python versions, operating systems, and native dependencies affect compatibility. Reusing downloaded package artifacts is often safer than blindly reusing an environment built under different conditions.

Step 8: Update Dependencies Safely

A lockfile should not be treated as permanent. Dependencies need security updates, bug fixes, and compatibility improvements.

When you want to update the resolved dependency versions, run:

uv lock --upgrade

This asks uv to upgrade packages within the constraints declared by the project.

Review the resulting lockfile changes, then run the test suite.

For a more targeted update, use the appropriate package-specific upgrade options supported by your installed uv version.

A useful dependency update process is:

  1. Update the lockfile.
  2. Review the package changes.
  3. Run unit and integration tests.
  4. Scan dependencies for known vulnerabilities where required.
  5. Merge the updated lockfile with the associated dependency changes.

Do not update every dependency automatically in production without testing the result. A lockfile makes changes visible, but it does not guarantee that newer package versions are compatible with your application.

Common Mistakes to Avoid

Best Practices for Repeatable Python Builds

Conclusion

uv makes Python dependency management easier by combining dependency resolution, lockfile generation, virtual environment management, and installation into a consistent workflow.

The key is to use pyproject.toml to declare the dependencies your application needs and commit uv.lock to record the resolved dependency set. In CI, use uv sync --locked so the build fails when the lockfile is missing or inconsistent instead of silently changing the dependency resolution.

Combine the lockfile with automated tests, controlled Python versions, and dependency updates that are reviewed before release.

These practices make Python builds more predictable and help reduce the dependency-related differences that often appear between development machines and production environments.