Skip to main content

105 - ML-Ops: UV, environments and dependencies that repeat identically

With UV you declare dependencies in pyproject.toml, pin them in uv.lock and recreate the same environment anywhere with uv sync --locked, Python included.

What do you think about this article?

105 - ML-Ops: UV, environments and dependencies that repeat identically
Machine Learning Engineerfedericocalo.dev

At a Glance

With UV you declare dependencies in pyproject.toml, pin them in uv.lock and recreate the same environment anywhere with uv sync --locked, Python included.

UV is a tool for managing Python projects: it installs Python versions, creates virtual environments, resolves and installs dependencies and records them in a lockfile. This lesson uses UV 0.12 and gives you one sentence to remember: the lockfile is the difference between "it works on my machine" and "it works everywhere". It closes the first of the five implicit dependencies of the notebook discussed in the module introduction: the library versions installed by chance.

The words of Python packaging

A package is a Python library distributed in installable form, with a name and a version: scikit-learn is a package, like an appliance on sale with a model and a serial number. PyPI is the large public warehouse from which uv and pip download. The version is the numeric label of a release, usually major.minor.patch (1.9.1): a "major" jump may break compatibility, a "patch" normally fixes bugs. The interpreter is the python program that runs the code, and libraries are built for some of its versions and not others. The virtual environment (.venv) is a folder with a Python and the libraries for this project only, each project's personal locker: two projects that want different versions of the same library do not step on each other's toes.

Resolution is the calculation by which the tool picks, for each package, a version compatible with the constraints of all the others, like composing a timetable in which nobody is in two classrooms at once. The lockfile (uv.lock) is the file where the result is written, with exact versions and hashes (fingerprints) of the downloaded files: it is the receipt. A development dependency is needed by whoever works on the project (for example pytest), not by whoever runs it in production, and in UV it lives in a group (dev).

The path, from intention to a working environment:

UV: pyproject.toml declares, resolution chooses the versions, uv.lock records them, uv sync builds the .venv environmentpyproject.tomlDECLAREDscikit-learn>=1.5Resolutionpicks consistentversionsuv.lockRESOLVEDversions + hashes.venvENVIRONMENTuv sync --lockeduv add / uv lock -> writes -> uv sync

The analogy: the shopping list and the receipt

Writing "flour, eggs, milk" on a slip of paper is a declaration: it says what you want, not which brand or which batch. The receipt, on the other hand, is a record of what you actually bought: flour from such-and-such mill, eggs from that batch, price and date. If you want to bake the exact same cake again, you need the receipt, not the slip. In UV the slip is pyproject.toml and the receipt is uv.lock.

The mechanism: declared and resolved

In pyproject.toml you write the constraints, for example scikit-learn>=1.5: "a version from 1.5 upwards". This constraint admits a great many versions, and a month from now a new one will exist. When UV resolves the dependencies it picks a concrete, consistent combination of all the packages, direct and transitive, and writes it in uv.lock with the exact versions and the hashes of the files. Whoever clones the project and syncs gets that combination, not the one the resolver would choose today.

The first project, step by step

I ran these commands on 2026-11-12 with UV 0.12.21 (versions chosen by the resolver that day: scikit-learn 1.9.1, pandas 3.1.1, pytest 9.2.0), twice in different folders with the same outputs:

uv init churn --python 3.12        # creates the folder with pyproject.toml, .python-version, src/
cd churn
uv add scikit-learn pandas         # adds the dependencies, creates .venv and uv.lock
uv add --dev pytest                # development-only dependency (group "dev")
uv run pytest --version            # runs a command INSIDE the project's environment
# pytest 9.2.0
uv tree --depth 1                  # the direct dependencies, as a tree
# churn v0.1.0
# ├── pandas v3.1.1
# ├── scikit-learn v1.9.1
# └── pytest v9.2.0 (group: dev)

After these commands you find pyproject.toml with the declared dependencies ("scikit-learn>=1.9.1": UV writes as the minimum constraint the version it has just installed), uv.lock with everything resolved, .venv/ with the environment, and .python-version with the Python version (3.12). uv run is the command you will use most: you do not have to "activate" the environment by hand, UV syncs and then runs. Everything goes into Git (Git lesson) except .venv/: pyproject.toml, uv.lock, .python-version, because the environment folder can be recreated.

Syncing in a locked way: the command that saves the release

On another machine, in a pipeline or in a container, the right command is uv sync --locked: it installs exactly the lockfile and, if it is not up to date, fails. I tried what happens when someone edits pyproject.toml by hand, adding tqdm, without regenerating the lock:

uv sync --locked
# Resolved 18 packages in 447ms
# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
#
# hint: To update the lockfile, run `uv lock`.
uv sync --frozen
# (no error: exit 0)
uv pip list | grep -i tqdm      # no line: tqdm was NOT installed

It is the right error, at the right moment: the declaration and the record do not match, and you are told so. There is also --frozen, which uses the lock without checking whether it is aligned: in the same test it finished with exit 0 and tqdm, added by hand, was not installed. For a release, --locked is safer, because it turns the inconsistency into an error instead of hiding it.

CommandWhenWhat it does if the lock is stale
uv syncDaily developmentUpdates the lock if needed
uv sync --lockedCI, container, deployFails with a clear error
uv sync --frozenRare cases where the lock is out of your controlProceeds without checking

Updating deliberately

The lockfile pins the versions, so the project does not change by itself. Updating becomes a voluntary, traceable act:

uv lock --upgrade-package scikit-learn   # updates only that package (and what it needs)
uv lock --upgrade                        # updates everything, within the constraints
git diff uv.lock                         # see what changed, then run the tests

The sequence is: update, read the diff, run uv run pytest, and commit only if it is green. A scikit-learn update can change a model's numbers: the threshold test is what tells you. In the test the two uv lock --upgrade commands changed nothing because the lock had been created a few minutes before; the diff was empty, and I did not run git diff because the test folder is not a repository.

The Python version trap

With uv init --bare --python 3.12 demo the project declares requires-python = ">=3.12" but does not create the .python-version file (verified: the folder contains only pyproject.toml). On the first uv run UV chose CPython 3.13.13, the most recent installed on the machine: the ">=3.12" constraint allows it. If the production image then uses Python 3.12, you developed on a different version from the one that runs. That is why uv python pin 3.12 writes the .python-version file (in the test, after the pin and an rm -rf .venv, UV answered Using CPython 3.12.13): the interpreter version is a dependency like the others. The same goes for saved models: a .joblib file produced with one version of scikit-learn may fail to load (or load with warnings) with another. Training and serving must share the lock; the compatibility of saved models across versions is stated in scikit-learn's documentation and I did not retest it here.

What is inside the files: reading pyproject.toml and uv.lock

Understanding the two files removes the mystery. I created a minimal project (uv init --bare --python 3.12 demo, then uv add "scikit-learn>=1.5" joblib).

[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "joblib>=1.6.0",
    "scikit-learn>=1.5",
]

requires-python says with which Pythons the project is guaranteed; dependencies lists the top-level constraints. Note that UV wrote joblib>=1.6.0 (the installed version) for the dependency added without a constraint, while it left scikit-learn>=1.5 as you typed it. The file is short on purpose, because people read it. The lockfile, instead, is read by the machine; here is the entry of one package, with addresses and hashes abbreviated:

[[package]]
name = "cloudpickle"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/.../cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb6...", size = 22330, ... }
wheels = [ { url = "https://files.pythonhosted.org/.../cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6...", size = 22228, ... } ]

For each package ([[package]]): name, exact version, where it comes from (source), and the addresses and hashes of the files (sdist = source code, wheels = already-built packages). The hash serves one precise purpose: if someone replaced the file on a mirror with a tampered one, the hash would not match and the installation would fail. The example project's lockfile has 252 lines (wc -l uv.lock) for 8 resolved packages, the project plus 7: nobody writes them, nobody edits them by hand. uv tree shows the relationships in readable form:

demo v0.1.0
├── joblib v1.6.0
│   └── cloudpickle v3.1.2
└── scikit-learn v1.9.1
    ├── joblib v1.6.0 (*)
    ├── narwhals v2.28.0
    ├── numpy v2.5.4
    ├── scipy v1.18.1
    │   └── numpy v2.5.4
    └── threadpoolctl v3.8.0
(*) Package tree already displayed

From the two dependencies you chose, five more packages descend (cloudpickle, narwhals, numpy, scipy, threadpoolctl); joblib appears twice because scikit-learn reaches it too, and the asterisk (*) marks a node already shown. In all, 7 installed packages, plus the project itself: the 8 that uv lock declares it resolved. These are the transitive dependencies discussed in the lesson on open source: the lockfile pins them all.

The environment is recreated, not repaired

The virtual environment is a derivative: you can delete it without losing anything. I did so on purpose:

rm -rf .venv
uv run python -c "import sys, sklearn; print(sys.version.split()[0], sklearn.__version__)"
# Using CPython 3.12.13
# Creating virtual environment at: .venv
# Installed 7 packages in 227ms
# 3.12.13 1.9.1

This changes the way you work: if the environment behaves strangely, you do not "fix" it by installing and uninstalling by hand (dirtying it): you throw it away and rebuild it from the lock. It is the simplest remedy for "it works on my machine". With UV's cache already warm, the rebuild took between 0.57 and 0.75 seconds in three runs (test machine, 2026-11-12); the first time, with the cache not yet ready for that environment, it took 8.4. With uv python list --only-installed you see the Python versions available on the machine: on mine there were 3.10, 3.11, 3.12, 3.13 and 3.14, each independent of the system one; if the requested version were not present, UV would download it.

Four commands that replace "pip install" in daily work

What you want to doCommandWhat happens
Add a libraryuv add pandasWrites it in pyproject.toml, resolves, updates uv.lock, installs in .venv
Remove a libraryuv remove pandasThe opposite: updates both files and the environment
Run something in the environmentuv run pytestSyncs the environment if needed, then runs
Use a one-off tooluvx pip-audit ...Runs it in a temporary environment, without dirtying the project

The conceptual difference from pip install is that uv add modifies the declaration, not just the environment: what you install stays written in the project. With pip install alone, the environment changes but no file records it: it is the recipe for "I installed something and no longer know what". (Of this table I ran uv add and uvx pip-audit; I did not try uv remove.)

Common errors and what they mean

The message error: The lockfile at uv.lock needs to be updated, but --locked was provided means that pyproject.toml and the lock do not match (seen above): you fix it with uv lock and by committing both. "No solution found when resolving dependencies" means that two packages ask for incompatible versions of a third: the message lists the chain of constraints and must be read from the bottom, and it is often solved by relaxing a constraint or updating one of the two (I did not reproduce this error). If the project works with uv run and not with python from the terminal, you are using the system Python and not the one in .venv: always use uv run, or activate the environment with source .venv/bin/activate. And if a notebook does not see the libraries, the Jupyter kernel is using another Python: start it from the project's environment, for example with uv run --with jupyter jupyter lab (a command from UV's documentation, not run here).

A habit that saves hours: committing the two files together

Every time a dependency changes, pyproject.toml and uv.lock go in the same commit. If they are separated, there is at least one commit where the project is inconsistent, and a git bisect (seen in the Git lesson) that passes through it gives false results. A simple rule to enforce with CI (lesson on manual deploy versus CI/CD) is a uv sync --locked step, which fails if the two files are misaligned.

The why: why a requirements.txt alone is not enough

The historical method for "declaring dependencies" in Python is a requirements.txt file: one line per package, often without a version or with a loose constraint. If you have used it, you have probably experienced the problem without naming it: the same file produces different environments at different times. It can be shown with an experiment. I wrote a two-line file (scikit-learn and pandas, no version) and asked UV to compute which versions it would install today, and which it would have installed on 1 January 2026 (the --exclude-newer option ignores everything published after the given date):

uv pip compile requirements.in --python-version 3.12
uv pip compile requirements.in --python-version 3.12 --exclude-newer 2026-01-01
PackageToday (12 November 2026)As of 1 January 2026
scikit-learn1.9.11.8.0
pandas3.1.12.3.3
numpy2.5.42.3.5
scipy1.18.11.16.3
joblib1.6.01.5.3

Same two-line file, two different environments: between the two dates pandas went from one major version (2) to another (3), the kind of jump in which behavior changes are allowed. Whoever installed in January and whoever installed today, following the same documentation, do not have the same project. This is not a defect of pip or UV: it is the consequence of what the file says, namely "I want any version, as long as it is compatible". The file describes a wish, not an environment.

There are five gaps in a typical requirements.txt, and it is worth naming them because they are exactly what a lockfile fills:

  1. Transitive dependencies are not pinned: if you pin scikit-learn==1.9.1 but not NumPy, NumPy is free to change underneath it.
  2. Hashes are missing: nothing tells you that the file downloaded today is the same one downloaded yesterday with the same version number. With --generate-hashes each line carries its fingerprint and the installation verifies it.
  3. It does not say with which Python: a package compatible with Python 3.12 may not be with 3.14.
  4. It does not separate what is needed to run from what is needed to develop, unless you keep several files and sync them by hand.
  5. It does not distinguish the "desired" from the "resolved": either you write loose constraints (and lose repeatability) or you write everything pinned (and lose readability and make every update very heavy).

The lockfile solves the last point by separating the two roles into two files, which is the reason for the pyproject.toml / uv.lock pair: the first is written for people, the second for machines. Even with the old tools you can do the same (uv pip compile turns a file of wishes, requirements.in, into a pinned file with all the versions, and with --generate-hashes adds the hashes), but UV integrates the two roles into a single flow.

What the lockfile guarantees, and what it leaves out

Saying that a lockfile "makes things reproducible" is only partly true, and the difference is a real skill. The lockfile guarantees the exact versions and the hashes of all Python packages, direct and transitive; it is universal (according to the UV documentation it is resolved once for all supported platforms and for all the Python versions allowed by requires-python), so the same file serves your laptop and the server.

It does not guarantee, and therefore must be managed elsewhere, six things:

  • The Python version: declared by .python-version (and the lock indicates only the requires-python range). Two files, two roles.
  • System libraries: many packages include native code that depends on the operating system. The lock pins the Python package, not the system beneath; to close the circle you need the container.
  • The hardware: the same model and the same version of a numerical library, on different processors or GPUs, can give slightly different results because of the order of floating-point operations. A test with tolerance (approx, in pytest) exists for this.
  • The data: the lock knows nothing about the dataset. If the input file changes, the result changes, with the same environment (you track them with MLflow).
  • The seeds: randomness not fixed gives different results with an identical environment.
  • The configuration: environment variables and secrets, covered in environment variables and secrets.

The six items are the missing links of a chain: reproducibility has six (code, dependencies, system, configuration, data, randomness) and a lockfile closes only one. Each lesson of the module closes another. When someone says "my experiment is not reproducible", the useful question is: which link?

The real case: a lock that is not used where it matters

This site's ML service contains, in the same folder, four files that talk about dependencies: pyproject.toml (the declaration), uv.lock (3,636 lines, the resolved), requirements.txt (110 lines) and requirements-authoring.txt (55 lines, for content processing; line counts with wc -l of 2026-11-12). The comment at the top of requirements.txt says: "Synchronized with pyproject.toml — the source of truth remains pyproject.toml. Update both when you change the dependencies."

The documented deploy procedure (docs/01_foundation/04_deploy/11_deploy_manuale.md) installs the package with pip install /opt/portfolio/repo/ml-service inside an environment that already exists on the server: in that step the versions are resolved from the constraints of pyproject.toml (for example fastapi>=0.115.0), not read from the lock. The lockfile therefore guarantees the development environment, but is not what the server installs. It is not an error to dramatize, it is a conscious trade-off or a debt to plan: the alternative would be a uv sync --locked in the deploy step. And two files that must say the same thing and are updated by hand are a debt of their own: sooner or later they will diverge, and no check will notice.

The comment is the mitigation of someone who knows the risk; the structural remedy would be to generate one from the other (uv export --no-dev --format requirements-txt produces a requirements.txt from the lock, tested) and to check with CI that they match. Moral: a lockfile that nobody uses at the critical point gives only the illusion of reproducibility. Always check where it is read.

Dependency groups: production and development

A production service does not need pytest. UV separates dependencies into groups: those of the project ([project] dependencies) and the named groups ([dependency-groups]), such as dev. In production you sync excluding the development groups:

uv sync --locked --no-dev
# Uninstalled 5 packages in 84ms
#  - iniconfig==2.3.1
#  - packaging==26.3
#  - pluggy==1.7.0
#  - pygments==2.21.0
#  - pytest==9.2.0
.venv/bin/python -c "import pytest"
# ModuleNotFoundError: No module named 'pytest'

After the command pytest and its four dependencies are uninstalled from the environment (UV lists the removed packages with the - sign), and import pytest gives ModuleNotFoundError. Beware of a trap I found while testing: a plain uv run python -c "import pytest" reinstalls the dev group before running, because uv run syncs with the default groups; to stay without dev you need uv run --no-dev (with which I get the same ModuleNotFoundError) or the .venv interpreter directly. With uv sync --locked the packages come back. Fewer packages in production means less attack surface (see the lesson on open source) and a smaller image. For optional features of your project there are extras (uv sync --extra name), meant for those who use your package as a library; I did not try them.

Single-file scripts with declared dependencies

Not everything is a project. For a one-evening script, UV supports inline metadata: a comment block at the top of the file that declares the Python version and the dependencies.

# /// script
# requires-python = ">=3.12"
# dependencies = ["scikit-learn>=1.5"]
# ///
import sklearn
print("sklearn", sklearn.__version__)
# Output of "uv run quick.py": sklearn 1.9.1

With uv run quick.py UV reads the block, creates a temporary environment with those dependencies, runs the script and prints sklearn 1.9.1 (tested in a folder without any UV project, with Installed 7 packages). The script carries its dependencies with it: you can send it to a colleague without explanations. It is the ideal intermediate step between "notebook" and "project": a single file, already declarative.

Migrating from pip and requirements.txt without drama

If you have an existing project with requirements.txt, the transition can be gradual:

  1. uv pip install -r requirements.txt: uses UV as a fast replacement for pip, changing nothing in the project.
  2. uv init in the folder, then uv add -r requirements.txt: imports the lines into pyproject.toml and creates the lock.
  3. Take requirements.txt out of the source of truth, or generate it from the lock when tools that require it need it.
  4. Run the tests, commit pyproject.toml and uv.lock together.

The order matters: the first step changes nothing and tells you whether the environment rebuilds, the second moves the declaration, the third avoids having two sources of truth. I tried the first two on a two-line requirements.txt (scikit-learn, pandas): uv add -r requirements.txt wrote "pandas>=3.1.1" and "scikit-learn>=1.9.1" in pyproject.toml. A practical tip: the first time, compare the versions before and after (pip freeze against uv tree), because the lock may choose versions more recent than the ones you had, and you had better know it before discovering it from a red test.

Updating with method

A lockfile pins the past, and the past gets old: new versions fix vulnerabilities and defects. A reasonable routine goes like this: every so often (at a cadence you decide: monthly, or at every release) run uv lock --upgrade in a dedicated branch; look at the diff of uv.lock to see which packages changed and by how much (major, minor, patch); run the whole test suite, because a version change in scikit-learn can shift a model's numbers and the threshold test is what warns you; if something breaks, update one package at a time with uv lock --upgrade-package name to isolate the culprit, a small manual bisection; finally run the security audit (pip-audit, as in the open source lesson) on the resulting lock.

The advantage of the approach is that the update becomes an event with a commit, a diff and tests, not an invisible change that happens the next time someone recreates the environment. Where the risk is high (models trained in the past that must keep being served), you do the opposite: you freeze, and update only with a deliberate retraining.

Loose, tight, or capped constraints

When you declare scikit-learn>=1.5 you make a bet: all future versions will be compatible. It is usually fine for minor versions, less so for major ones. Three styles:

StyleExampleWhen
Minimum onlyscikit-learn>=1.5Applications: the lock does the pinning work
Compatiblescikit-learn~=1.9 (from 1.9 up to before 2.0)When you want to exclude major jumps known to break
Explicit cappandas>=2.2,<3When you know a major version breaks you and you are not ready

For applications (a service) the rule is to leave constraints loose and let the lock work: the lock already acts as a precise block. For libraries that others will install, loose constraints are instead a favor to your users: too tight, and your package does not coexist with others in the same environment. The ~= syntax is that of "compatible release" in the version specifiers specification.

UV versus pip

pip is everywhere and works; UV adds the lockfile and Python management in a single tool and is very fast, but it is one more tool to install. With uv pip install you keep the familiar interface, and pinning the versions gives repeatability at the price of an update routine: without it, you miss security fixes.

Try it yourself: four guided exercises

Exercise 1: the same declaration at two different times

Create requirements.in with two lines (scikit-learn and pandas). Run uv pip compile requirements.in --python-version 3.12 and then again with --exclude-newer and a date a few months ago (in the YYYY-MM-DD format). You should see different versions for several packages (in my test, pandas 3.1.1 against 2.3.3 with the date 1 January 2026). What you learn: that a file without versions describes a wish, and that the --exclude-newer option is a way to "travel in time" and reproduce an environment from the past.

Exercise 2: throw away the environment and recreate it

In a working UV project, delete the .venv folder and rerun uv run python -c "import sklearn; print(sklearn.__version__)". You should see Creating virtual environment, Installed N packages and the printed version, in a few seconds (between 0.57 and 0.75 in my tests, with a warm cache). What you learn: that the environment is a derivative and should not be "repaired"; and the value of having everything declared.

Exercise 3: provoke the lockfile error

Edit pyproject.toml by hand, adding a dependency that is not already installed (for example tqdm>=4.66) without running uv lock. Then uv sync --locked. You should see error: The lockfile at uv.lock needs to be updated, but --locked was provided. Now try uv sync --frozen and check with uv pip list that the library was not installed. Finally uv lock and uv sync --locked. What you learn: the difference between a check that warns you and one that leaves you in ambiguity. Pick a library not yet present in the environment: if it were already a transitive dependency, the exercise would show nothing.

Exercise 4: the script with inline metadata

Write a quick.py file with at the top the # /// script ... # /// block that declares requires-python and one dependency, and that prints the library's version. Run it with uv run quick.py in a folder without any UV project. You should see UV create a temporary environment, install the dependency and print the version. What you learn: that for a one-evening script you do not need to create a project, and that the script carries with it what it needs.

If you want a fifth exercise: run uv sync --locked --no-dev and check with the .venv interpreter that pytest disappears from the environment, then uv sync --locked to get it back. It is the difference between the production environment and the development one, in a single command.

What you can do now

You can declare dependencies in pyproject.toml, have them recorded in uv.lock and version the two files together. You can work with uv run, release with uv sync --locked and update with uv lock --upgrade-package while knowing how to read the diff. You can pin the Python version with .python-version, tell what the lock guarantees from what it leaves out, and check where a lock is really read, because a lock ignored by the deploy does not protect. Next lesson: pytest, the tests that let you sleep at night.

The same series also includes ML-Ops: multi-stage Dockerfile for an ML service, non-root user and healthcheck, ML-Ops: model registry, versions, aliases and promoting a model and ML-Ops: deployment, where the model runs and how to choose.

Sources

All the executable blocks (UV 0.12.21, Python 3.12.13 and 3.13.13, 2026-11-12) were run twice in different folders with identical outputs, except for the times, reported as a range; the package versions are those chosen by the resolver that day and will change. Not run: uv remove, uv run --with jupyter jupyter lab, the extras, the "No solution found" error. The project name "churn" and the module's data are didactic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects