Things I wish I’d known about uv

Python
Package management
Author

Safouane Chergui

Published

September 16, 2026


Last year, I wrote about why I ditched conda & pip for Pixi. While I do love Pixi, I often find myself working with uv in my activity as a consultant as most teams I’ve worked in the last couple of years have it in their toolchain.

When I started using uv, a colleague of mine showed me a couple of commands and started working with it right away. That same colleague also told me that uv has a pip-compatible API that I can use to make my life easier at first.

While his guidance helped me get started quickly, it’s that same guidance that made me really struggle later and not understand some things that were going on with my uv usage.

The goal of this blog post is to share some painpoints and some lessons that I’ve learnt throughout this year. This is not an introductory blog to how to use uv, ample resources can be found online for this.

All the commands shown in this blog post have been run using

!uv --version
uv 0.12.9 (9f9286029 2026-09-01 aarch64-apple-darwin)

Before getting started

Almost all the commands in uv come down to modifying 2 files and a folder:

  • pyproject.toml

  • uv.lock

  • .venv folder

The quickest way to see it is to add a package to a fresh project. I created mine with

# The `--bare` flag gives a project made of nothing but a `pyproject.toml`
uv init --bare --python 3.14 --name uv_blog_demo
Initialized project `uv-blog-demo`

Let us now install pandas and see what happens to the files above

uv add pandas
Using CPython 3.14.6 interpreter at: /opt/homebrew/opt/python@3.14/bin/python3.14
Creating virtual environment at: .venv
Resolved 6 packages in 498ms
Prepared 2 packages in 1.91s
Installed 4 packages in 25ms
 + numpy==2.5.3
 + pandas==3.0.5
 + python-dateutil==2.9.0.post0
 + six==1.17.0

With this command alone, all three have seen their content change.

pyproject.toml holds ranges rather than exact versions. I typed uv add pandas without asking for any version in particular, and uv wrote down a lower bound:

dependencies = [
    "pandas>=3.0.5",
]

The bounds of dependencies can be controlled but this is not the topic of this post.

uv.lock is where the Resolved 6 packages line of the output ended up. Ranges are gone and every package gets one exact version, a hash per downloadable file, and markers saying on which platforms it is needed.

[[package]]
name = "pandas"
version = "3.0.5"
dependencies = [
    { name = "numpy" },
    { name = "python-dateutil" },
    { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = ".../pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712" }
wheels = [ ... ]

If someone wants to reproduce your environment and wants to have the same .venv as you, uv.lock is what makes it possible, as it contains the specific versions you will use in your .venv.

They do need pyproject.toml next to it though. Running uv sync in a folder that contains only uv.lock stops right away:

error: No `pyproject.toml` found in current directory or any parent directory

Note that uv does dependency resolution for other platforms as well, not just the one you’re using. This can be controlled as well but it’s outside the scope of this blog post.

The impact on .venv is the Installed 4 packages line, the packages that were installed in .venv/lib/python3.14/site-packages. Only 4 packages were installed and not 6 packages because:

  • tzdata doesn’t need to be installed on mac, and is only included in case it’s win32 or emscripten

  • The sixth entry is uv-blog-demo, the project itself. As we’ve run uv init with --bare, it is not a package, so there is nothing to install for it.

Commands in uv usually impact one or many files at once.

The chain between the three files is always the same, and it has two steps:

pyproject.toml    pandas>=3.0.5
      │
      │ resolve
      ▼
uv.lock           pandas 3.0.5 + hashes + markers
      │
      │ install
      ▼
.venv             pandas/ and numpy/ in site-packages

What changes from one command to the next is how much of that chain it walks through:

  • uv lock runs resolve only. It reads the ranges in pyproject.toml, picks exact versions and writes them to uv.lock, without touching .venv.
  • uv sync runs resolve, then install. It re-locks if pyproject.toml has moved ahead of uv.lock, then makes .venv match the lockfile exactly, which includes removing packages that are not in it.
  • uv add pandas edits pyproject.toml first, then runs the resolve and install step.
  • uv run runs the same two steps before running your command, with one difference: it installs what is missing but leaves extra packages alone.

You’ll usually find yourself working with uv add, uv sync and uv run

Also, it’s important to keep in mind that the flow also only ever goes downward. Whatever you do directly to .venv is never recorded in uv.lock, and whatever is in uv.lock never edits pyproject.toml. This rule is what the next section is about.

1. uv add or uv pip install ?

First thing to know is that uv add pandas and uv pip install pandas are not equivalent at all!

Looking back at the three files of the previous section, the difference is easy to state: uv add walks down all three layers, while uv pip install only touches the bottom one.

uv add writes to the three files. Let us add rich to the project:

uv add rich
Resolved 10 packages in 281ms
Installed 4 packages in 12ms
 + markdown-it-py==4.2.0
 + mdurl==0.1.2
 + pygments==2.21.0
 + rich==15.0.0

pyproject.toml gets the range:

dependencies = [
    "pandas>=3.0.5",
    "rich>=15.0.0",
]

uv.lock gets the exact version that was picked:

[[package]]
name = "rich"
version = "15.0.0"

and the four packages are now in .venv. Note that only rich made it to pyproject.toml, as it is the only one I asked for. The three others are its dependencies, so they live in the lockfile and in .venv, but not in pyproject.toml.

uv pip install only writes to .venv. Say I am in the middle of something and I just want to plot a dataframe quickly, so I grab matplotlib the way I used to with pip:

uv pip install matplotlib
Resolved 11 packages in 658ms
Prepared 7 packages in 1.73s
Installed 8 packages in 16ms
 + contourpy==1.4.0
 + cycler==0.12.1
 + fonttools==4.65.0
 + kiwisolver==1.5.1
 + matplotlib==3.11.2
 + packaging==26.3
 + pillow==12.3.0
 + pyparsing==3.3.2

matplotlib is installed and I can import it right away, but searching for it in the other two files returns nothing:

grep -c matplotlib pyproject.toml uv.lock
pyproject.toml:0
uv.lock:0

Nothing wrote it down. A colleague cloning the repository and running uv sync will not get matplotlib.

And it disappears the day someone syncs. uv sync makes .venv match the lockfile exactly, which also means removing whatever is not in it:

uv sync
Resolved 10 packages in 3ms
Uninstalled 8 packages in 83ms
 - contourpy==1.4.0
 - cycler==0.12.1
 - fonttools==4.65.0
 - kiwisolver==1.5.1
 - matplotlib==3.11.2
 - packaging==26.3
 - pillow==12.3.0
 - pyparsing==3.3.2

One nuance that cost me a couple of hours to get my head around: uv run does not delete the extra packages installed in .venv. Straight after uv pip install matplotlib, running uv run python -c "import matplotlib" works. By default, uv run installs what is missing but leaves extra packages alone, while uv sync performs an exact sync, removing any extra packages that don’t appear in in the lock file. So an environment polluted with uv pip install keeps working for days, until the day you run uv sync and then your installed packages gets uninstalled.

So when should you use uv pip install? In my experience, never.

And if, like in the example above, you only need a package for one command, there is a better option:

uv run --with matplotlib python -c "import matplotlib"
Installed 11 packages in 33ms
3.11.2

matplotlib was available for the duration of that one command, and pyproject.toml, uv.lock and .venv were all left untouched.

The behavior of uv sync and uv run can be changed by passing arguments to these commands but I highly recommend to stick to the default behavior of each, it just makes it easier to remember what each does.

2. The dev group is the default!

So now we have an environment with pandas and you want to add pytest to the group of your dev dependencies, the command is easy:

uv add --dev pytest

and the result shows that pytest was installed

Resolved 15 packages in 296ms
Installed 4 packages in 6ms
 + iniconfig==2.3.0
 + packaging==26.3
 + pluggy==1.6.0
 + pytest==9.1.1

Your pyproject.toml should show something similar to this:

dependencies = [
    "pandas>=3.0.5",
    "rich>=15.0.0",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
]

The question is what happen next time that you do uv sync, is uv going to install the dev dependencies or not just the real dependencies of your project ?

Let delete completely the existing .venv and redo a uv sync for the result to be crystal clear

rm -rf .venv && uv sync
Using CPython 3.14.6 interpreter at: /opt/homebrew/opt/python@3.14/bin/python3.14
Creating virtual environment at: .venv
Resolved 15 packages in 2ms
Installed 12 packages in 28ms
 + iniconfig==2.3.0
 + markdown-it-py==4.2.0
 + mdurl==0.1.2
 + numpy==2.5.3
 + packaging==26.3
 + pandas==3.0.5
 + pluggy==1.6.0
 + pygments==2.21.0
 + pytest==9.1.1
 + python-dateutil==2.9.0.post0
 + rich==15.0.0
 + six==1.17.0

uv didn’t install just pandas (and its dependencies), it also installed the dev dependencies (pytest in this case) even though nobody asked for them.

What you should know is that uv considers the group with the name dev as the default. Upon running uv sync with no additional arguments, it will always install your base dependencies with the dev dependencies on top of them.

When I was first using uv, this behavior startled me as I was expecting to install just the base dependencies, the ones appearing in dependencies in pyproject.toml.

If you create some other group for linting purpose and put ruff inside of it, it will not be installed by default unless specified explicitly.

uv add --group lint ruff

Run uv sync and you’ll see that ruff is not installed.

Two things to know before moving on from this section: - uv sync --no-default-groups installs just the base dependencies - You can set the default groups to be installed with uv sync by default using default-groups entry in pyproject.toml

[tool.uv]
default-groups = ["dev", "lint"]

Other nice uv cli commands exist, I recommend that you check the docs for that.