writing

uv is npm and nvm for Python

Coming from Node, Python's tooling always felt scattered across pip, venv, pyenv, and pipx — and it's the main reason I kept bouncing off the language. uv collapses all of it into a single tool that maps almost one-to-one onto the npm and nvm workflow you already know. Here's the whole loop, built around one tiny script.

Michael Movsesov
Michael MovsesovCo-authored with Claude

For years, the thing that kept me away from Python wasn't the language. It was everything around the language. 😅 I'd sit down to try some small idea, immediately hit the wall of pip vs venv vs pyenv vs pipx, spend twenty minutes figuring out which one owned which job, and quietly close the tab.

If you're coming from Node, you know how good we have it. npm handles packages, node runs the code, nvm swaps runtime versions, and the jobs are split so cleanly that you never really think about who owns what. Python spreads those exact same jobs across four separate tools, and picking the right one is its own little learning curve before you've written a single line of code.

Then I found uv, and honestly? It fixed the whole thing. It installs packages, manages virtual environments, pins the Python version, runs your code, and it'll even download Python itself if the version you asked for isn't on your machine. One binary, all four jobs. And the best part, the part that made me actually stick with it, is how closely it maps onto the Node tools you already know. This isn't a new country. It's the same trip with a phrasebook.

Four tools become one

Here's that phrasebook. Lined up against the Node tools uv stands in for, the overlap is almost comforting:

uvNodeWhat it does
uv initnpm initScaffold a new project
uv add / uv removenpm install / npm uninstallAdd or remove a dependency
uv runnodeRun the code
uv syncnpm ciInstall everything from the lockfile
.python-version.nvmrcPin the runtime version

That table is most of the mental model, right there. The rest is worth seeing in motion, though, so let's build the smallest possible project together and watch what uv does at each step.

Starting from an empty folder

We start in an empty folder and run uv init:

uv init

That one command scaffolds a project, and a few of the files it drops will feel like old friends:

uv fileNode equivalentRole
pyproject.tomlpackage.jsonProject metadata and dependency list
.python-version.nvmrcPins the runtime version
main.pyindex.jsThe entry script
.gitignore.gitignoreSame file, pre-filled for Python
README.mdREADME.mdPlaceholder readme

The one to actually care about is pyproject.toml. It's the package.json of the Python world — the file you 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, sure, but squint and every piece lines up. Those [section] headers are TOML, which leans on headings instead of nested braces. requires-python is your engines field, declaring which Python versions the project supports. And dependencies is the list uv add will fill in for you, exactly the way npm install writes into package.json.

There's one line in the generated main.py that tends to trip people up on the way over 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 some other file imports it." It's Python's version of if (require.main === module) — there to stop an import from accidentally kicking off the whole script as a side effect.

The first run does more than run

In Node you'd reach for node main.py without a second thought. In uv it's uv run, and the very first run does a surprising amount of work on your behalf:

$ uv run main.py
Using CPython 3.14.6
Creating virtual environment at: .venv
Hello from python-uv-learning!

Before it printed a single character, uv read the Python version from .python-version, created a .venv folder, and wrote a uv.lock file — none of which we asked it to do. Two of those are worth slowing down for.

The .venv folder is the closest thing Python has to node_modules: an isolated copy of Python plus every package the project installs. In Node this isolation is invisible, because you get it for free — every project has its own node_modules and nobody loses sleep over it. Python's older default was the exact opposite, and it's worth picturing to appreciate what uv is saving you from. Imagine every npm install writing into one giant node_modules shared by every project on your whole machine. Now imagine two projects that need different versions of the same package, quietly overwriting each other's stuff. That's the mess. A virtual environment is the fix: a private workbench for each project, its own tools laid out where nothing from the next project over can touch them. uv builds that workbench and keeps it tidy, so you never activate anything by hand. It's git-ignored, just like node_modules.

The uv.lock file is your package-lock.json: the exact, fully resolved version of every package, pinned so the environment can be rebuilt identically on another machine. You commit it, and you don't edit it. That split is the entire workflow in one sentence — you touch pyproject.toml and the .py files, and uv keeps uv.lock and .venv in sync on every command. Set-it-and-forget-it, and it just works. ✨

Adding a dependency

Let's make the script actually do something. In Node you'd run npm install axios; here we'll add requests, the go-to library for HTTP calls:

$ uv add requests
Installed 5 packages
 + certifi
 + charset-normalizer
 + idna
 + requests
 + urllib3

We asked for one package and got five. The other four are what requests itself depends on — the same way npm install express quietly pulls in a whole tree of sub-packages. The distinction that matters shows up back in pyproject.toml, which lists only the thing we asked for:

dependencies = [
    "requests>=2.34.2",
]

Only requests is here, because it's our only direct dependency. The other four live in uv.lock, which records the full resolved tree. It's the same division of labor you already know: package.json lists your direct dependencies while package-lock.json captures everything underneath. 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.0

With 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 here read differently from JavaScript, and they're worth a quick callout. import requests is your 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 notice what's missing: there's no await anywhere. requests.get() blocks and hands you the response directly, so the synchronous style you'd be fighting against in Node is just the default here. Run it again and it skips all the setup, since the environment already exists:

$ uv run main.py
GitHub Zen says: Approachable is better than simple.

Handing it to someone else

Here's where the lockfile earns its keep: the moment someone else clones your project, or you 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 sync

This is npm ci. It reads the resolved versions, recreates .venv, and installs precisely what the lockfile pins — no surprise upgrades, no drift. And that .venv folder? Totally disposable. Delete it whenever you feel like it and uv sync will build the whole workbench back in seconds.

Same muscle memory, fewer tools

That's the whole loop: init, run, add, sync. Four commands, one binary, and a mental model you mostly already had from Node — you're just spending it on a different phrasebook.

What won me over wasn't any single feature. It was that Python stopped feeling like a pile of tools I had to assemble before I could start, and started feeling like the thing Node has always felt like: open a folder, write some code, run it. If Python tooling is the reason you've been bouncing off the language too, this is the thing I'd hand you. Give it an afternoon. I think it'll click. 😄