This is mostly an update to parts of the reproducible Python development box blog I've written up earlier. However, this blog post can be read as a stand-alone article.

At the time, I used a combination of poetry to manage diffierent virtual environments for different projects, pyenv to handle different python versions, and pipx to manage python CLI independently of any project. However, with the recent updates to uv (from the same makers of ruff, which I assume you already know about- but if not I highly recommend it as a replacement for flake8, isort and black), the functionality of all of these tools has been combined into a single tool.

uv has been touted as a fast replacement of pip for a while, but recently its scope has greatly expanded (due to the incorporation of rye functionality, a tool which was adopted by Astral). This offers a good opportunity to clean up the Python toolchain.

This will not be a detailed review of uv functionality, because you can just read the documentation. But I did want to update my previous blog now that uv can be used to replace many (if not all) Python dependency management tools.

Dependency management

uv replaces poetry as a dependency management system. There are multiple files in which dependencies can be specified, but using the uv init command to create a skeleton project (very similar to poetry init), will create a pyproject.toml file. This is where uv add (similar to poetry add) will add dependencies by default.

Since both tools use pyproject.toml, one might assume migrating from poetry to uv will be easy, but the configuration is different between tools, so be aware of this before migrating. We will also have a uv.lock file rather than a poetry.lock, and the two are not compatible.

From the documentation you can probably figure out how to pilot this tool easily enough, so I just wanted to highlight some differences with poetry that I personally really like:

  • By default, uv bundles the virtual environment with the repository folder under .venv. Supposedly poetry has a configuration option for this as well, but I never found it. I prefer this because it makes running poetry shell unnecessary.

Using uv run (the analogue to poetry run) will work and use this virutal environment, but I have the following in my config.fish to make switching to virtual environments easier:

abbr -a venv source .venv/bin/activate.fish
  • The --dev flag exists for uv add and is not deprecated. There are also optional dependencies that can be specified in yet another way (namely under the [project.optional-dependencies] node). This is a bit redundant, but I like the convenience.

  • Like in poetry, you can specify local dependencies, but they fall under their own configuration. An example looks like this:

[tool.uv.sources]
stockallocationmodel = { path = "../stockallocationmodel", editable = true }
network_planner = { path = "../networkplanning/network_planner", editable = true}

The uv documentation itself recommends using workspaces for projects consisting of multiple Python packages, but I personally haven't had the chance to try these out yet.

  • uv does not have its own build backend, while poetry does. Using uv init will by default give us the setup for hatchling. Make sure you have these lines included, because otherwise I found builds will likely fail for arcane reasons:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Being written in Rust and therefore blazingly fast, it advertises an ability to fetch and install dependencies much faster than its competitor. Personally, anything that takes longer than 10 seconds falls under coffee break in my schedule, so I am not so susceptible to this argument.

Python version management

I personally love poetry and wouldn't make the switch just for slightly faster install times. However, poetry does not have a built-in way to manage different Python versions.

For this we can use pyenv, but I didn't much care for it because of the redundant .python-version file. I often struggled having to manually synchronize the two and interact with pyenv to get the versions right.

With uv, Python version switching just works. Many commands have a --python flag to specify versions, and the uv python command has a whole set of subcommands dedicated to managing Python versions.

There's built-in supportcpython, obviously, but for pypy as well. Fuzzy search is provided, so uv python install pypy gives me the latest pypy version for my architecture.

In pyproject.toml, there's a dedicated requires-python setting which can let you specify minimum, maximum and exact python versions. More complex version boundaries, such as '>=3.8,<3.10', are also possible. But what's different is that the rest of the uv commands will actually be proactive in providing that specific Python version, as opposed to poetry, which only prohibits you from proceeding until you figure out yourself how to get the right version yourself.

This functionality the main reason for me to make the switch, because it's extremely convenient to not have to struggle with Python versions anymore. The fact that it consolidates two tools into one, means it makes pyenv redundant.

You can also use uv python pin to create a .python-version file, and use uv as a pyenv replacement for poetry projects (if you really wanted to for some reason).

Python CLI tools

For tools in Python that you don't want to tie to a specific virtual environment, the uv tool set of commands exists. This is a pipx replacement, essentially, and uv has the alias uvx just to drive this point home.

The main commands work as expected. That is, similar to pipx, which in turn work similar to pip (uv also has uv pip, which is its pip replacement). list, install and upgrade and uninstall are all present, as expected. Unfortunately, pipx inject is not (this is a command that lets you add additional packages into the virtual environments pipx erects around a tool, which can be useful if you want more control over the dependencies of a Python tool).

For unix systems, uv will put symbolic links in your .local/bin directory that direct to the tools it installs, so that these tools are available directly from the shell. However, you can also use uvx {toolname} if you prefer to call it this way, or in case of path conflicts.

A point of interest to note is that uvx {toolname} will automatically fetch tools that it cannot find locally. E.g. uvx ruff would fetch ruff from its own cache or download it, and run it as a one-off command. This will not add it to your list of tools (uv tool list). Personally I'm not a fan of commands doing multiple things in this way, but it is clever how uv leverages its own cache to quickly provide tools on-the-fly.

Conclusion

This was meant to be a recommendation and update blog rather than a full feature review.

Not mentioned here is that uv replaces many other Python tools, such as pip or the virtualenv package, which in turn already have replacements that fully or partially overlap with the functionality of the tools I mentioned in this blog. Taking this into account, it could be argued that uv is yet another incarnation of the competing standards problem that causes tools to propagate infinitely.

However, I've been sold on uv pretty much the day I started using it. I admit I was an easy sell, because the poetry/pyenv interop had been annoying me for a while, but I am still very impressed with the ease-of-use of uv once I figured out the commands essential to my workflow.

Furthermore, astral has already shown it is capable of providing useful but not bloated replacement tools with ruff, which is also a must-have in all Python projects for me and many others. Considering this, and the relative feature completeness of uv, it seems like a sure bet to learn and use for your Python projects.