Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

2.2 Virtual Environments and Dependency Management

In the previous section we created a uv package project. We now turn to the everyday work of using one: adding the libraries our code needs, keeping the install reproducible across machines, and isolating it from the rest of the system.

To do this uv ties three artefacts together for you:

These three artefacts belong to the same workflow. A single command — uv add, uv remove, uv sync or uv run — updates whichever of them is out of date. You should rarely, if ever, edit uv.lock or touch .venv by hand.

The Project Virtual Environment

A virtual environment is a private Python installation that belongs to one project. Packages installed into it are invisible to the system Python and to your other projects, which is what allows two projects on the same machine to depend on different versions of the same libraries without conflict.

In a uv project, this environment lives in a .venv/ directory at the project root. You do not create it manually: uv creates .venv/ (and uv.lock) the first time you run any project command, for example:

uv run python --version
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Using CPython 3.12.3 interpreter at: /usr/bin/python3.12
Creating virtual environment at: .venv
Installed 1 package in 1ms
Python 3.12.3

or, equivalently, an explicit sync:

uv sync

.venv/ is machine- and platform-specific (a Linux .venv/ will not work on Windows), so it should never be committed. uv writes its own .gitignore inside the directory to enforce this, and it can recreate the whole environment from pyproject.toml and uv.lock at any time.

Adding dependencies

Let’s give the package something to do that requires an external library. Create a small script analysis.py in src/my_package:

import pandas as pd
df = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
print(df)

Now add pandas as a dependency:

uv add pandas
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 6 packages in 156ms
Prepared 2 packages in 386ms
Uninstalled 1 package in 0.71ms
Installed 5 packages in 40ms
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 + numpy==2.4.4
 + pandas==3.0.3
 + python-dateutil==2.9.0.post0
 + six==1.17.0

A single uv add does three things at once:

  1. writes pandas into project.dependencies in pyproject.toml,

  2. resolves the full dependency graph and records the chosen versions in uv.lock,

  3. installs those versions into .venv/.

There is no separate “install” step. The pyproject.toml now looks roughly like this:

cat pyproject.toml
[project]
name = "my-package"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "mcallara", email = "msdp.book@gmail.com" }
]
requires-python = ">=3.12"
dependencies = [
    "pandas>=3.0.3",
]

[project.scripts]
my-package = "my_package:main"

[build-system]
requires = ["uv_build>=0.11.14,<0.12.0"]
build-backend = "uv_build"

Understanding Version Constraints

uv writes dependency constraints using the standard Python dependency specifier syntax. A constraint like pandas>=2.3.1,<3 reads as “at least 2.3.1, but strictly less than 3.0.” Such a range admits patch and minor releases — which conventionally contain bug fixes and backwards-compatible additions — while blocking the next major version, where breaking changes are allowed. The principles behind that convention (semantic versioning) are the subject of a later chapter.

Adding Dependencies from an existing project with requirements.txt

If you are migrating an existing project that already has a requirements.txt, you can add all the dependencies in one go with uv add -r requirements.txt. This reads the dependencies from the file and adds them to pyproject.toml with the same version constraints, then resolves and installs them as usual.

Running Code in the Project Environment

The canonical way to execute anything inside the project is uv run:

uv run python -m my_package.analysis
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
   A  B
0  1  4
1  2  5
2  3  6

Before launching the command, uv checks that uv.lock is consistent with pyproject.toml and that .venv/ matches uv.lock, syncing if necessary. In effect, uv run is the “just run my project” command — you do not have to remember to install or activate anything.

If you need to issue several commands in the same shell, you can fall back to the classic activate-the-venv pattern:

uv sync
source .venv/bin/activate
python -m my_package.analysis

This is useful for interactive sessions, but for one-off commands prefer uv run.

Updating and Removing Dependencies

To upgrade a single package while leaving the rest of the lockfile untouched:

uv lock --upgrade-package pandas
Resolved 6 packages in 63ms

To upgrade every package within the existing constraints:

uv lock --upgrade

In both cases, uv lock only updates uv.lock; the new versions are installed the next time you run uv sync or uv run. (You can also pass --upgrade-package directly to uv sync or uv run to do both in one step.)

To remove a dependency:

uv remove pandas
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 1 package in 2ms
Prepared 1 package in 3ms
Uninstalled 5 packages in 51ms
Installed 1 package in 2ms
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 - numpy==2.4.4
 - pandas==3.0.3
 - python-dateutil==2.9.0.post0
 - six==1.17.0

Organising Dependencies

So far we have only talked about runtime dependencies. These are the ones listed in the [project.dependencies] section of pyproject.toml. In a real project, you will likely have several different kinds of dependencies. For example, you might have some packages that are only needed during development (e.g. pytest), and some that are only needed for optional features (e.g. matplotlib for plotting). You might also want to group your runtime dependencies by concern (e.g. postgres drivers vs. redis drivers). To allow for that, uv gathers dependencies into three categories: runtime (that will appear in [project.dependencies]), development (that will appear in [dependency-groups]) and optional (that will appear in [project.optional-dependencies]) — and allows you to create custom groups as well.

The Development Dependencies

The development dependencies are packages used during development of the project. They are recorded in pyproject.toml under the [dependency-groups] section but they are not part of the package’s published metadata, so users who pip install my-package will never see them.

The development dependencies are conventionally put in a group called dev. To add a package to that group, use the --dev flag with uv add. For example, to add pytest as a development dependency in the dev group:

uv add --dev pytest
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 7 packages in 110ms
Prepared 2 packages in 27ms
Uninstalled 1 package in 0.62ms
Installed 6 packages in 14ms
 + iniconfig==2.3.0
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 + packaging==26.2
 + pluggy==1.6.0
 + pygments==2.20.0
 + pytest==9.0.3

The result in pyproject.toml:

[dependency-groups]
dev = [
    "pytest>=8.4.1"
]

The dev group is a special case in uv — it is synced automatically and has matching --no-dev / --only-dev flags to include or exclude it.

You can also create custom development groups to organise your dependencies by concern. For example, you might want to have a lint group for linters and a docs group for documentation tools:

uv add --group lint  ruff
uv add --group docs  sphinx
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 8 packages in 140ms
Prepared 2 packages in 125ms
Uninstalled 1 package in 0.53ms
Installed 2 packages in 3ms
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 + ruff==0.15.12
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 28 packages in 138ms
Prepared 5 packages in 23ms
Uninstalled 1 package in 0.48ms
Installed 21 packages in 60ms
 + alabaster==1.0.0
 + babel==2.18.0
 + certifi==2026.4.22
 + charset-normalizer==3.4.7
 + docutils==0.22.4
 + idna==3.15
 + imagesize==2.0.0
 + jinja2==3.1.6
 + markupsafe==3.0.3
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 + requests==2.34.0
 + roman-numerals==4.1.0
 + snowballstemmer==3.0.1
 + sphinx==9.1.0
 + sphinxcontrib-applehelp==2.0.0
 + sphinxcontrib-devhelp==2.0.0
 + sphinxcontrib-htmlhelp==2.1.0
 + sphinxcontrib-jsmath==1.0.1
 + sphinxcontrib-qthelp==2.0.0
 + sphinxcontrib-serializinghtml==2.0.0
 + urllib3==2.7.0

This creates two new groups in pyproject.toml:

[dependency-groups]
lint = [
    "ruff>=0.0.241"
]
docs = [
    "sphinx>=7.2.6"
]

Optional Dependencies

An optional dependency is the opposite: it is part of the published metadata, behind a named “extra” that the user opts into at install time. This is recorded under project.optional-dependencies, and a downstream user installs the extra with pip install my-package[plotting].

For example, to add matplotlib as an optional dependency for a “plotting” extra:

uv add --optional plotting matplotlib
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 38 packages in 195ms
Prepared 2 packages in 169ms
Uninstalled 1 package in 0.38ms
Installed 11 packages in 23ms
 + contourpy==1.3.3
 + cycler==0.12.1
 + fonttools==4.62.1
 + kiwisolver==1.5.0
 + matplotlib==3.10.9
 ~ my-package==0.1.0 (from file:///home/runner/work/msdp-book/home/ch42/my-package)
 + numpy==2.4.4
 + pillow==12.2.0
 + pyparsing==3.3.2
 + python-dateutil==2.9.0.post0
 + six==1.17.0

This creates a new extra in pyproject.toml:

[project.optional-dependencies]
plotting = [
    "matplotlib>=3.7.2"
]

Choosing What to Install

By default, uv sync installs the runtime dependencies plus every default group. The only default group out of the box is dev, but the set is configurable via tool.uv.default-groups in pyproject.toml. Non-default groups are skipped unless you ask for them. You can narrow or broaden that selection — useful in CI, where you often want only what is strictly needed for the job:

# Runtime only — skip all default groups (typically `dev`)
uv sync --no-default-groups

# Runtime plus an additional, non-default group
uv sync --group docs

# Default install minus one group
uv sync --no-group dev

# Only one group, no runtime dependencies and no other default groups
uv sync --only-group dev

Note that optional dependencies (the [project.optional-dependencies] extras above) are a separate axis: they are not installed by uv sync unless you ask for them with --extra <name> or --all-extras.

For example, to install the plotting extra we added above:

uv sync --extra plotting
warning: `VIRTUAL_ENV=/home/runner/work/msdp-book/msdp-book/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
Resolved 38 packages in 0.92ms
Uninstalled 21 packages in 114ms
 - alabaster==1.0.0
 - babel==2.18.0
 - certifi==2026.4.22
 - charset-normalizer==3.4.7
 - docutils==0.22.4
 - idna==3.15
 - imagesize==2.0.0
 - jinja2==3.1.6
 - markupsafe==3.0.3
 - requests==2.34.0
 - roman-numerals==4.1.0
 - ruff==0.15.12
 - snowballstemmer==3.0.1
 - sphinx==9.1.0
 - sphinxcontrib-applehelp==2.0.0
 - sphinxcontrib-devhelp==2.0.0
 - sphinxcontrib-htmlhelp==2.1.0
 - sphinxcontrib-jsmath==1.0.1
 - sphinxcontrib-qthelp==2.0.0
 - sphinxcontrib-serializinghtml==2.0.0
 - urllib3==2.7.0

Version Control

When using uv in a project, you should commit pyproject.toml and uv.lock to version control, but never commit .venv/. The reason is that pyproject.toml and uv.lock together make the project reproducible on any machine, while .venv/ is machine- and platform-specific and can be rebuilt at any time with uv sync.

To ignore .venv/, you can rely on the fact that uv writes a .gitignore inside the .venv/ directory as soon as it creates it, so the contents are excluded from git automatically. However, it’s still good practice to add .venv/ to your project’s top-level .gitignore for clarity:

.venv/

Recap

File / directoryPurposeIn version control?
pyproject.tomlDeclares what the project needsYes
uv.lockRecords the exact resolved versionsYes
.venv/Isolated environment where packages are installedNo

And the four commands you will reach for most often:

Resources and Further Reading