· 7 min read
uv: Python versions, venvs and projects, with examples
A hands-on guide to uv: installing Python versions, managing virtual environments, building a complete project, single-file scripts, tools, and whether you really need a src directory.
uv is a fast Python package and project manager from Astral, the makers of Ruff. One tool replaces a whole shelf of others:
| You used to reach for | uv gives you |
|---|---|
| pyenv | uv python install, uv python pin |
virtualenv, python -m venv | uv venv, or automatic .venv in projects |
| pip, pip-tools | uv pip install, uv lock, uv sync |
| Poetry, PDM | uv init, uv add, uv run, uv build |
| pipx | uvx, uv tool install |
It’s written in Rust and is usually many times faster than the tools it replaces. The real win, though, is that one tool understands Python versions, environments and dependencies together.
Every command and output in this post was run with uv 0.13.7. uv moves quickly, so if something looks different on your machine, check uv --version and the docs.
The mental model
uv deals with five things. Once you know which one you’re working with, the commands make sense:
- Python versions. Interpreters, installed and found by uv.
- Virtual environments. A
.venvfolder holding one Python and its packages. - Projects. A folder with a
pyproject.tomland auv.lock. uv manages the.venvfor you. - Scripts. A single
.pyfile that declares its own dependencies. - Tools. Command-line programs written in Python, such as Ruff or HTTPie, installed in isolation.
Installing and updating uv
curl -LsSf https://astral.sh/uv/install.sh | sh # install
uv self update # update a standalone install
uv --version
If you installed uv another way, such as with Homebrew or pipx, update it the same way instead.
Part 1: managing Python versions
See what’s available
uv python listcpython-3.16.0a1-linux-x86_64-gnu <download available>
cpython-3.15.2-linux-x86_64-gnu <download available>
cpython-3.14.10-linux-x86_64-gnu <download available>
cpython-3.14.7-linux-x86_64-gnu /home/you/.local/bin/python3.14 -> ...
cpython-3.13.18-linux-x86_64-gnu <download available>
cpython-3.12.3-linux-x86_64-gnu /usr/bin/python3.12
...
pypy-3.12.15-linux-x86_64-gnu <download available>
graalpy-3.13.0-linux-x86_64-gnu <download available>
uv lists both the Pythons it can download and the ones already on your system, including your operating system’s own. It also offers free-threaded builds and the PyPy and GraalPy interpreters. Add --only-installed to see just what you have.
Install, upgrade and remove versions
uv python install 3.13 # latest 3.13.x
uv python install 3.12 3.13 # several at once
uv python upgrade 3.13 # move to the newest 3.13 patch release
uv python uninstall 3.12 # remove one
uv python find 3.13 # print the path to an interpreter
uv python dir # where uv keeps its Pythons$ uv python install 3.13
Installed Python 3.13.18 in 1.75s
+ cpython-3.13.18-linux-x86_64-gnu (python3.13)
uv’s Pythons live in ~/.local/share/uv/python, and uv adds a python3.13 link in ~/.local/bin. They don’t touch your system Python, so they can’t break the operating system’s tools.
Use a specific version once
uv run --python 3.13 python --version # Python 3.13.18
uvx [email protected] --version # Python 3.12.16
You rarely need to install Pythons by hand. When a project or command asks for a version you don’t have, uv downloads it automatically. When a matching interpreter is already on your system, uv uses that instead.
Pin a version for a project
uv python pin 3.13
This writes a .python-version file. Every uv command in that folder then uses Python 3.13. Commit the file so the whole team gets the same version.
Part 2: a complete project, start to finish
Let’s build a small command-line tool, countdown, that prints the days until a date. It uses Pendulum, is tested with pytest and is linted with Ruff.
Create the project
uv init --python 3.13 countdown
cd countdown
uv creates:
countdown/
├── .git/ # a new git repository
├── .gitignore # already ignores .venv, __pycache__, dist/
├── .python-version # 3.13
├── README.md
├── pyproject.toml
└── src/
└── countdown/
└── __init__.py
and this pyproject.toml:
[project]
name = "countdown"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Your Name", email = "[email protected]" }
]
requires-python = ">=3.13"
dependencies = []
[project.scripts]
countdown = "countdown:main"
[build-system]
requires = ["uv_build>=0.13.7,<0.14.0"]
build-backend = "uv_build"
The [project.scripts] table turns the main function into a countdown command. The [build-system] table makes this an installable package. That’s why uv used a src directory, which Part 3 explains.
Add dependencies
uv add pendulum # a runtime dependency
uv add --dev pytest ruff # development-only dependencies$ uv add pendulum
Using CPython 3.13.18
Creating virtual environment at: .venv
Resolved 4 packages in 531ms
Installed 4 packages in 8ms
+ countdown==0.1.0 (from file:///.../countdown)
+ pendulum==3.3.0
+ python-dateutil==4.0.0
+ tzdata==2026.6
The first uv add created .venv, resolved every dependency and wrote uv.lock. Your pyproject.toml now has:
dependencies = [
"pendulum>=3.3.0",
]
[dependency-groups]
dev = [
"pytest>=9.2.0",
"ruff>=0.17.4",
]
uv remove pendulum takes a dependency out again. To pin a range yourself, quote it: uv add "requests>=2.32".
Write the code
src/countdown/__init__.py:
import sys
import pendulum
def days_until(target: str, today: pendulum.Date | None = None) -> int:
"""Whole days from today until the target date (YYYY-MM-DD)."""
today = today or pendulum.today().date()
return (pendulum.from_format(target, "YYYY-MM-DD").date() - today).days
def main() -> None:
target = sys.argv[1] if len(sys.argv) > 1 else f"{pendulum.today().year}-12-25"
print(f"{days_until(target)} days until {target}")
tests/test_countdown.py:
import pendulum
from countdown import days_until
def test_days_until():
assert days_until("2026-12-25", today=pendulum.date(2026, 12, 1)) == 24
def test_today_is_zero():
assert days_until("2026-11-09", today=pendulum.date(2026, 11, 9)) == 0Run, test and lint
$ uv run countdown 2026-12-25
46 days until 2026-12-25
$ uv run pytest -q
.. [100%]
2 passed in 0.09s
$ uv run ruff check
All checks passed!
uv run is the command you’ll use most. Before running anything, it checks that .venv matches uv.lock and fixes it if not. You never have to remember to install after pulling a teammate’s changes.
Do I still activate the venv?
You don’t have to. uv run always uses the project’s environment. If you prefer an activated shell, that still works:
source .venv/bin/activate # Windows: .venv\Scripts\activate
countdown 2027-01-01 # 53 days until 2027-01-01
deactivate
Just remember that an activated shell doesn’t sync for you: after changing dependencies, run uv sync.
Lock and sync
uv.lock records the exact version of every package, for every platform. Commit it. Never commit .venv.
uv sync # make .venv match uv.lock exactly
uv sync --locked # fail if uv.lock is out of date (use in CI)
uv sync --no-dev # production: skip the dev group
uv lock --upgrade-package pendulum # upgrade one package
uv lock --upgrade # upgrade everything within your constraints
uv tree # show the dependency tree$ uv tree
countdown v0.1.0
├── pendulum v3.3.0
│ ├── python-dateutil v4.0.0
│ │ └── tzdata v2026.6
│ └── tzdata v2026.6
├── pytest v9.2.0 (group: dev)
│ ├── iniconfig v2.3.1
│ ├── packaging v26.3
│ ├── pluggy v1.7.0
│ └── pygments v2.21.0
└── ruff v0.17.4 (group: dev)
A new teammate’s whole setup is now:
git clone <your repo> && cd countdown
uv run pytest
uv installs the right Python, creates .venv, installs the locked dependencies and runs the tests.
Change the project’s Python version
$ uv python pin 3.14
Updated `.python-version` from `3.13` -> `3.14`
$ uv run python --version
Using CPython 3.14.10
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Installed 10 packages in 19ms
Python 3.14.10
uv rebuilt the environment for the new version on its own. It also refuses a version the project doesn’t support:
$ uv python pin 3.12
error: The requested Python version `3.12` is incompatible with the project `requires-python` value of `>=3.13`.
To support older Pythons, lower requires-python in pyproject.toml first.
Version and build
$ uv version --bump minor
countdown 0.1.0 => 0.2.0
$ uv build
Successfully built dist/countdown-0.2.0.tar.gz
Successfully built dist/countdown-0.2.0-py3-none-any.whl
uv publish uploads those files to PyPI when you’re ready.
Part 3: does uv have to use a src directory?
No. This is the most common point of confusion, because the answer depends on which kind of project you ask for. Tutorials online show both layouts, depending on the uv version and flags they used.
What matters is whether the project is a package: something uv builds and installs into .venv, with a [build-system] table. In uv 0.13:
| Command | Layout | Packaged? | Best for |
|---|---|---|---|
uv init or uv init --app | src/<name>/__init__.py | Yes | Command-line tools, anything you’ll install or share |
uv init --lib | src/<name>/ plus py.typed | Yes | Libraries other projects import |
uv init --no-package | main.py at the top level | No | Scripts, notebooks, web apps you deploy as a folder |
The flat, non-packaged project
uv init --no-package hello
creates:
hello/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── main.py
└── pyproject.toml
There’s no [build-system] table and no src. uv add and uv run python main.py work exactly as before, and uv still manages .venv and uv.lock. It just doesn’t install your code as a package. This is a perfectly good choice for a Django or Flask app, a data project or a collection of scripts.
Why packaged projects use src
With a flat layout, Python finds your package simply because it’s in the current directory. With a src layout, it can only find the copy that’s actually installed:
$ cd demo-package # src layout
$ python3 -c "import demo_package"
ModuleNotFoundError: No module named 'demo_package'
$ cd flatpkg # same package, flat layout
$ python3 -c "import demo_package"
# works: imported straight from ./demo_package/
That failure is a feature. It means your tests run against the installed package, the same way your users will get it. Packaging mistakes, like a missing file or a broken entry point, show up in your tests instead of in someone else’s bug report. Inside the project, uv run installs the package for you, so everything just works.
A packaged project without src
If you want a package but dislike src, tell the build backend where your code lives:
[tool.uv.build-backend]
module-root = ""
Then move src/countdown/ up to countdown/. uv run countdown and uv build work as before.
Part 4: plain venvs and the pip interface
Not every project needs pyproject.toml. For an existing project with a requirements.txt, uv works as a much faster pip:
$ uv venv --python 3.12
Using CPython 3.12.3 interpreter at: /usr/bin/python3.12
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
$ uv pip install -r requirements.txt
+ requests==2.34.2
+ rich==15.0.0
+ urllib3==2.8.0
...
Notice it used the system’s Python 3.12 rather than downloading one. Other familiar commands work too:
uv pip list
uv pip freeze > requirements.lock
uv pip uninstall rich
uv pip installs into the active venv, or into .venv in the current folder. Unlike plain pip, it refuses to install into your system Python:
$ uv pip install rich
error: No virtual environment found; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environmentMoving a requirements.txt project to uv
uv init --no-package
uv add -r requirements.txtdependencies = [
"requests>=2.31",
"rich>=15.0.0",
]
If a server or another tool still needs a requirements.txt, generate one from the lock file:
uv export --format requirements-txt --no-hashes > requirements.txtPart 5: single-file scripts with their own dependencies
This is one of uv’s nicest features. A script can declare its dependencies in a comment block, and uv run builds a cached environment for it automatically. You don’t need a project, a venv or a pip install.
uv init --script stars.py --python 3.13
uv add --script stars.py requests rich
uv writes the header for you. stars.py:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "requests>=2.34.2",
# "rich>=15.0.0",
# ]
# ///
import requests
from rich import print
r = requests.get("https://api.github.com/repos/astral-sh/uv", timeout=10)
r.raise_for_status()
print(f"[bold green]uv[/bold green] has {r.json()['stargazers_count']:,} stars on GitHub")$ uv run stars.py
Installed 9 packages in 27ms
uv has 91,827 stars on GitHub
The first line is optional. With it, plus chmod +x stars.py, you can run the script directly as ./stars.py on Linux and macOS. Copy the file to another machine with uv installed and it just works.
A few more script tricks:
uv lock --script stars.py # pin exact versions in stars.py.lock
uv run --with rich python -c "import rich" # borrow a package for one commandPart 6: tools
Python command-line tools like Ruff, Black or HTTPie don’t belong in your project’s dependencies or your system Python. uv runs them in their own isolated environments.
Run once with uvx
uvx ruff --version # ruff 0.17.4
uvx [email protected] --version # a specific version
uvx --from httpie http --version # when the command name differs from the package
uvx is short for uv tool run. It caches the environment, so the second run is instant.
Install permanently
uv tool install ruff
uv tool install --python 3.13 httpie
uv tool list
uv tool upgrade --all
uv tool uninstall httpie$ uv tool list
httpie v3.2.4
- http
- httpie
- https
ruff v0.17.4
- ruff
Installed tools go in ~/.local/bin. If your shell can’t find them, run uv tool update-shell once.
Part 7: format, check and audit
uv 0.13 has project-wide quality commands. All three are still marked experimental and print a warning saying so.
uv format # format the project with Ruff (experimental)
uv format --check # fail if anything needs formatting (CI)
uv check # type-check with ty, Astral's type checker (experimental)
uv audit # look for known vulnerabilities in dependencies (experimental)
Cheat sheet
| Task | Command |
|---|---|
| Install a Python | uv python install 3.13 |
| List Pythons | uv python list --only-installed |
| Pin a project’s Python | uv python pin 3.13 |
| New packaged project (src) | uv init myapp |
| New flat project | uv init --no-package myapp |
| New library | uv init --lib mylib |
| Add a dependency | uv add requests |
| Add a dev dependency | uv add --dev pytest |
| Remove a dependency | uv remove requests |
| Run in the project env | uv run pytest |
| Sync after pulling | uv sync |
| CI install | uv sync --locked |
| Upgrade one package | uv lock --upgrade-package requests |
| Show dependency tree | uv tree |
| Bump version, build | uv version --bump minor, uv build |
| Plain venv | uv venv --python 3.12 |
| pip-style install | uv pip install -r requirements.txt |
| Import requirements.txt | uv add -r requirements.txt |
| Export requirements.txt | uv export --format requirements-txt |
| Script dependencies | uv add --script tool.py requests |
| Run a tool once | uvx ruff check |
| Install a tool | uv tool install ruff |
| Update uv | uv self update |
Where to learn more
- uv documentation, especially the projects guide and the scripts guide.
uv help <command>for every option, such asuv help init.- Python: what it does best for the libraries to put in your new project.