uv is npm and nvm for Python
Coming from Node, Python tooling always felt scattered across pip, venv, pyenv, and pipx. uv collapses all of it into a single tool that maps almost one-to-one onto the npm and nvm workflow we already know. Here is the whole loop, built around one tiny script.
Coming from Node, the part of Python that always tripped me up was the tooling, not the language. Node hands us npm and node out of the box, and nvm covers switching runtime versions, so the jobs are neatly divided and it is obvious which tool owns what. Python spreads those same jobs across pip, venv, pyenv, and pipx, and figuring out which one to reach for is its own small learning curve before we have written a line of code.
uv collapses all of that into a single binary. It installs packages, manages virtual environments, pins the Python version, and runs the code, and it downloads Python itself if the version we asked for is missing. The nice surprise is how closely it maps onto tools we already know from Node, so most of what we need is a translation table rather than a new set of concepts.
One binary instead of four
Lined up against the Node tools it stands in for, the overlap is almost comforting:
| uv | Node | What it does |
|---|---|---|
uv init | npm init | Scaffold a new project |
uv add / uv remove | npm install / npm uninstall | Add or remove a dependency |
uv run | node | Run the code |
uv sync | npm ci | Install everything from the lockfile |
.python-version | .nvmrc | Pin the runtime version |
That is most of the mental model. The rest is worth seeing in motion, so let us build the smallest possible project and watch what uv does at each step.
Starting a project
We start in an empty folder and run uv init:
uv initThat one command scaffolds a project, and a few of the files it drops will look familiar:
| uv file | Node equivalent | Role |
|---|---|---|
pyproject.toml | package.json | Project metadata and dependency list |
.python-version | .nvmrc | Pins the runtime version |
main.py | index.js | The entry script |
.gitignore | .gitignore | Same file, pre-filled for Python |
README.md | README.md | Placeholder readme |
The pyproject.toml is the one to pay attention to. It is the package.json of the Python world, the file we actually edit by hand:
[project]
name = "python-uv-learning"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []The shape is different from JSON, but the pieces line up. Those [section] headers are TOML, which leans on headings instead of nested braces. requires-python is the equivalent of the engines field, declaring which Python versions the project supports, and dependencies is the list uv add will fill in for us, the same way npm install writes into package.json.
The generated main.py has one line that tends to confuse people arriving from JavaScript:
def main():
print("Hello from python-uv-learning!")
if __name__ == "__main__":
main()That if __name__ == "__main__": guard means "only run this when the file is executed directly, not when it is imported by another file". It is Python's version of if (require.main === module), there to keep an import from triggering the script as a side effect.
The first run
In Node we would reach for node main.py. In uv it is uv run, and the first run does more than run:
$ uv run main.py
Using CPython 3.14.6
Creating virtual environment at: .venv
Hello from python-uv-learning!Before printing anything, uv picked the Python version from .python-version, created a .venv folder, and wrote a uv.lock file, all without being asked. Two of those are worth understanding.
The .venv folder is the closest thing Python has to node_modules: an isolated copy of Python and every package the project installs. In Node this isolation is invisible because we get it for free, every project has its own node_modules and nobody thinks about it. Python's older default was the opposite. Picture every npm install writing into one machine-wide node_modules shared by every project, and two projects that need different versions of the same package quietly stepping on each other. A virtual environment is the fix, a private sandbox per project, and uv creates and manages it so we never activate anything by hand. It is git-ignored, like node_modules.
The uv.lock file is the package-lock.json: the exact, fully resolved version of every package, pinned so the environment can be rebuilt identically on another machine. We commit it and we do not edit it. That split is the whole workflow in a sentence, we touch pyproject.toml and the .py files, and uv keeps uv.lock and .venv in sync on every command.
Adding a dependency
Let us make the script do something. In Node we would run npm install axios; here we add requests, the standard library for HTTP calls:
$ uv add requests
Installed 5 packages
+ certifi
+ charset-normalizer
+ idna
+ requests
+ urllib3We asked for one package and got five. The other four are what requests depends on, exactly like npm install express pulling in a tree of sub-packages. The distinction that matters shows up back in pyproject.toml, which now lists only the thing we asked for:
dependencies = [
"requests>=2.34.2",
]Only requests is here because it is the only direct dependency. The other four live in uv.lock, which records the full resolved tree. It is the same division of labor as package.json listing direct dependencies while package-lock.json captures everything underneath. If we want to see that tree, uv tree prints it the way npm ls does:
$ uv tree
python-uv-learning v0.1.0
└── requests v2.34.2
├── certifi v2026.7.22
├── charset-normalizer v3.4.9
├── idna v3.18
└── urllib3 v2.7.0With the package installed, the script can use it:
import requests
def main():
response = requests.get("https://api.github.com/zen", timeout=10)
response.raise_for_status()
print(f"GitHub Zen says: {response.text}")
if __name__ == "__main__":
main()A couple of things read differently from JavaScript. import requests is the require, and uv makes sure the package is found inside .venv without any path juggling. The f"...{response.text}" is an f-string, Python's template literal. And there is no await in sight, requests.get() blocks and returns the response directly, so the synchronous style we would fight in Node is just the default here. Running it again skips all the setup, since the environment already exists:
$ uv run main.py
GitHub Zen says: Approachable is better than simple.Reproducing it elsewhere
The lockfile earns its keep the moment someone else clones the project, or we set it up on a fresh machine. Because pyproject.toml and uv.lock are committed and .venv is not, a single command rebuilds the exact environment from the lock:
uv syncThis is npm ci. It reads the resolved versions, recreates .venv, and installs precisely what the lockfile pins, no surprise upgrades. The .venv folder is disposable, we can delete it whenever we like and uv sync will build it back.