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:
- Managing project dependencies in
pyproject.toml. - Resolving package versions.
- Generating a
uv.locklockfile. - Creating and synchronizing virtual environments.
- Installing dependencies from the lockfile.
- Supporting development dependency groups.
- Integrating dependency installation into CI pipelines.
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-serviceThis 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 httpxThis 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 ruffThis 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 lockThe 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:
| File | Purpose |
|---|---|
pyproject.toml | Declares project metadata, Python requirements, and dependency constraints |
uv.lock | Records 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 syncThis 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 --lockedThe --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-devThis 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 pytestThis 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 pytestThis 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:
- It checks out the source code.
- It selects the intended Python version.
- It installs
uv. - It synchronizes dependencies without changing the lockfile.
- 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.lockThis 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 --upgradeThis 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:
- Update the lockfile.
- Review the package changes.
- Run unit and integration tests.
- Scan dependencies for known vulnerabilities where required.
- 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
- Forgetting to commit
uv.lock: Other developers and CI may resolve different versions. - Using
uv syncwithout enforcing lockfile consistency in CI: An outdated lockfile may be updated instead of causing the build to fail. - Treating version constraints as exact versions: A range in
pyproject.tomldoes not identify a single release. - Updating dependencies without running tests: New versions can introduce behavior changes or compatibility issues.
- Assuming the lockfile captures the entire environment: Python versions, operating systems, native libraries, and external services still matter.
- Relying entirely on CI caches: A build should work correctly even when its cache is empty.
- Editing the lockfile manually: Use
uvto maintain dependency resolution.
Best Practices for Repeatable Python Builds
- Commit both
pyproject.tomlanduv.lock. - Use
uv sync --lockedin CI. - Declare runtime and development dependencies clearly.
- Keep Python version requirements consistent across local development and CI.
- Review lockfile changes during dependency updates.
- Run tests after changing package versions.
- Use caching to improve build speed without making correctness depend on cached files.
- Rebuild environments when the Python version or platform changes.
- Scan dependencies regularly for known security vulnerabilities.
- Document the standard commands developers should use.
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.

Join the conversation! Your thoughts help the community grow.