August 28, 2025 . By Reuven
Want to use uv effectively? Take my free, 12-part “uv crash course,” at https://uvCrashCourse.com/ .
Like many others in the Python world, I’ve adopted “uv“, the do-everything, lightning-fast package manager written in Rust.
uv does it all: For people who just want to download and install packages, it replaces pip. For people who want to keep multiple versions of Python on their computer, it replaces pyenv. For people who want to work on multiple projects at the same time using virtual environments, it handles that, too. And for people who want to develop and distribute Python software, it works for them, also.
Here’s the thing, though: If you’re using uv as a replacement for one of these tools or problems, then you’re probably using it wrong. Yes, uv is a superset of these tools. But the idea is to sweep many of these things under the rug, thanks to the idea of a uv “project.” In many ways, a project in uv allows us to ignore virtual environments, ignore Python versions, and even ignore pip.
I know this, because I’ve used uv the wrong way for quite a while. It was so much faster than pip that I started to say
uv pip install PACKAGE
instead of
pip install PACKAGE
But actually, that’s not quite true — I use virtual environments on software projects, but 99% of my work is teaching Python via one-off Jupyter notebooks, which means I don’t have to worry about package and version conflicts. This being the case, I would just install packages on my global Python installation:
uv pip install --system PACKAGE
Which works! However, this isn’t really the way that things are supposed to be done.
So, how are we supposed to do things?
uv assumes that everything you do will be in a “project.” Now, uv isn’t unique in this approach; PEP 518 (https://peps.python.org/pep-0518/) from way back in 2016 talked about projects, and specified a file called pyproject.toml that describes a minimal project. The file’s specifications have evolved over time, and the official specification is currently at https://packaging.python.org/en/latest/specifications/pyproject-toml/.
For many years, Python programs were just individual files. A bunch of files could be put together into a single folder and treated as a package. The term “project” was used informally at companies and working groups, or among people who wrote Python editors, such as PyCharm and VSCode.
Even without a formal definition, we all kind of know what a package is — a combination of Python and other files, all grouped together into one whole, to solve one set of problems.
A pyproject.toml file is meant to be the ultimate authority regarding a project. TOML format is similar to an INI configuration file, but with Python-like data structures such as strings and integers. It also supports version numbers and comparison operators, allowing us to indicate exact, approximate, “not less than” and “not more than” versions for dependencies.
The minimal, initial pyproject.toml file looks like this:
[project]
name = "myproj"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []
As you can see, it only defines a “project” section, and then a number of name-value pairs. You can see that I called this project “myproj”, and that I created it using Python 3.13 — hence the “requires-python” line. It doesn’t do anything just yet, which is why it has an empty list of dependencies.
How and when do you create such a project? How does it intersect with Python’s virtual environments? How does it intersect with a Python version manager, such as pyenv, about which I’ve written previously?
Here’s the secret: To use uv correctly, you ignore pyenv. You ignore pip. You ignore venv. You just create and work with a Python project. If you do that from within uv, then you’ll basically be letting uv do all of the hard work for you, papering over all of the issues that Python packaging has accumulated over the years.
The thing is, uv does offer a variety of subcommands that let you work with virtual environments, Python versions, and package installation. So it’s easy to get lulled into using these parts of uv to replace one or more of them. But if you do that, you’re missing the point, and the overall design goals, of uv.
So, how should you be using it?
First: You create a project with “uv init”. It is possible to retroactively use uv on an existing directory of code, but let’s assume that you want to start a brand-new project. You say
uv init myproj
This creates a subdirectory, “myproj”, under the current directory. This directory contains:
- .git, the directory containing Git repo information. So yes, uv assumes that you’ll manage your project with Git, and already initializes a new repo.
- .gitignore, with reasonable defaults for anyone coding in Python. It’ll ignore __pycache__ directories, pyo and pyc compiled files, the build and dist subdirectories, and a variety of other file types that we don’t need to store in Git.
- .python-version, a file that tells uv (and pyenv, if you’re using it) what version of Python to use
- main.py, a skeleton file that you can modify (or rename) to use as the base for your application
- pyproject.toml, the configuration file I described earlier
- README.md,
Once the project is created, you can write code to your heart’s content, adding files and directories as you see fit. You can commit to the local Git repo, or you can add a remote repo and push to it.
So far, uv doesn’t seem to be doing much for us.
But let’s say that we want to modify main.py to download the latest edition of python.org and display the number of bytes contained on that page. We can say:
import requests
def main():
print("Hello from myproj!")
r = requests.get('https://python.org')
print(f'Content at python.org contains {len(r.content)} bytes.')
if __name__ == "__main__":
main()
If you run it with “python main.py”, you’ll find that it works just fine, printing a greeting and the number of bytes at python.org.
But you shouldn’t be doing that! Using “python main.py” means that you’re running whatever version of Python is in your PATH. That might well be different from what uv is using. And (as we’ll see in a bit) it likely has access to a different set of packages than uv’s installation might have.
Rather, you should be running the program with “uv run python main.py”. Running your program via “uv” means that it’ll take your pyproject.toml configuration file into account.
Why would we care? Because pyproject.toml is shared among all of the people working on your project. It ensures that they’re in sync regarding not only the version of Python you’re using, but also the libraries and tools you’re using, too. (We’ll get to packages in just a moment.) If I make sure to configure everything correctly in “pyproject.toml”, then everyone on my team will have an identical environment when they run my code. It also means that if we install our code on a project system, it’ll also use things correctly.
So, what happens when I run it?
❯ uv run python main.py
Using CPython 3.13.5 interpreter at: /Users/reuven/.pyenv/versions/3.13.5/bin/python3.13
Creating virtual environment at: .venv
Traceback (most recent call last):
File "/Users/reuven/Desktop/myproj/main.py", line 1, in <module>
import requests
ModuleNotFoundError: No module named 'requests'
As we can see, “requests” is not installed. But wait — we just saw that it’s installed on my system. Indeed, we got a response back from the program!
This is where anyone familiar with virtual environments will start to nod their head, saying, “Good! uv is ensuring that only packages installed in the virtual environment for this project will be available.”
And indeed, you can see that uv noticed a lack of a venv, and created one in a hidden subdirectory, “.venv”. So “uv run” doesn’t just run our program, it does so within the context of a virtual environment.
If you’re expecting us to start using “activate” and “pip install” within a venv, you’ll be sadly mistaken. That’s because uv wants to shield us from such things. Instead, we’ll add one or more files to pyproject.toml using “uv add”:
❯ uv add requests
Here’s what I get:
Resolved 6 packages in 42ms
Installed 5 packages in 13ms
+ certifi==2025.8.3
+ charset-normalizer==3.4.3
+ idna==3.10
+ requests==2.32.4
+ urllib3==2.5.0
These packages were installed in .venv/lib/python3.13/site-packages, which is what we would expect in a virtual environment. But you can mostly ignore the .venv directory. That’s because the most important file is pyproject.toml, which we see has been changed via “uv add”:
[project]
name = "myproj"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.32.4",
]
We now have one dependency, requests with a version of at least 2.32.4.
In a traditional venv-based project, we would activate the venv, pip install, run, and then deactivate the venv. In the uv world, we use uv to add to our pyproject.toml and to run our program with “uv run”. In both cases, the venv is used, but that usage is mostly hidden from view.
But wait a second: It’s nice that we indicated what version of requests we need. But what about the packages that requests requires? Moreover, what if our program also requires NumPy, which has components that are compiled from C? How can we be sure that everyone who downloads this project and uses “uv run” is going to use precisely the same versions of the same packages?
The answer is another configuration file, called “uv.lock”. This file is written and maintained by uv, and shouldn’t ever be touched by us. It should, however, be committed to Git and distributed to everyone running the project. When you use “uv run”, uv checks “uv.lock” to ensure that all of the needed packages are installed, and that they are all compatible with one another. If it needs, it’ll download and install versions that are missing, too. And “uv.lock” includes the precise filenames that are needed for each package, for each supported architecture and version of Python — for the packages that we explicitly list as dependencies, and those on which the dependencies themselves rely.
If you adopt uv in the way it’s meant to be used, you thus end up with a workflow that’s less complex than what many Python developers have used before. When you need a package, you “uv add” it. When you want to run your program, you “uv run” it. And so long as you make sure to check “uv.lock” into Git, then anyone else downloading, installing, and running your program via “uv run” will be sure that all libraries are installed and compatible with one another.