deep·tech·intuition
intermediate ·

uv Deep Intuition

An experienced engineer's guide to uv

1. One-Sentence Essence

uv is a statically-compiled Rust binary that owns the entire lifecycle of a Python project — interpreter, virtual environment, dependency resolution, lockfile, installation, build, and publish — replacing the half-dozen Python-implemented tools that traditionally split that job.

Everything else about uv — the speed, the universal lockfile, the disposable environments, the disk savings — flows from that one architectural choice. It’s not a faster pip. It’s a coherent take on what Python’s tooling stack would have looked like if it were designed once, in 2024, by someone who’d seen Cargo work.


2. The Problem It Solved

For roughly fifteen years, Python’s package management was a mosaic of tools that each solved one slice of the problem: pip installed packages, virtualenv made isolated environments, pyenv switched interpreters, pip-tools produced lockfiles, pipx ran CLI tools in their own envs, poetry and pdm tried to unify some of that, twine published packages. None of them owned the whole loop. They all had to be glued together with shell scripts, Makefiles, and tribal knowledge.

There were three deeper structural problems underneath the fragmentation:

Python tools written in Python create a chicken-and-egg problem. To install pip, you need Python. To install a different Python with pyenv, you need a working shell environment that may or may not have one. Bootstrap sequences in CI and Dockerfiles became absurd. And because the tools ran in Python, they paid Python’s startup cost on every invocation — a cold pip install of a hundred packages could spend more time importing its own modules than downloading wheels.

Resolution was slow, sequential, and platform-locked. pip’s resolver (added properly only in 2020) backtracks naively when it hits conflicts. A complex ML project could spend a minute resolving and another five minutes installing. Worse, pip freeze > requirements.txt produced a snapshot of one machine — the team’s macOS laptop and the production Linux servers got different lockfiles, or they got the same one and prayed.

Installation re-did the same I/O over and over. Each virtualenv got its own copy of numpy. If you had ten projects, you had ten copies. There was no concept of a global cache that deduplicated across environments; pip’s wheel cache stored downloaded archives, but installation still meant unpacking and writing every byte to a new directory.

uv’s creator, Charlie Marsh — who’d already shipped ruff (the Rust-based Python linter that runs ~100x faster than flake8) — looked at this and made one bet: write the whole stack in Rust, treat the global cache as a first-class deduplicating filesystem, use a real SAT-style resolver, and produce a single lockfile that works on every platform. First release was February 2024. By late 2024 it was production-ready; by 2026 it’s the default choice in most new Python projects.


3. The Concepts You Need

These are the terms that show up everywhere in uv. Get fluent in them now and the rest of the document reads cleanly.

Project structure

  • pyproject.toml — the single file that declares your project’s name, version, dependencies, and tool config. Standardized in PEP 621. uv uses this as the source of truth; you almost never edit it by hand for dependencies (you use uv add), but you do edit it for tool config.
  • uv.lock — the lockfile. Contains the exact resolved versions, hashes, and source URLs for every direct and transitive dependency, across all platforms and Python versions your project supports. Not edited by hand. Committed to version control.
  • .python-version — a one-line file (e.g. 3.12) pinning the project’s Python version. uv reads it on every run and will install that Python automatically if it’s missing.
  • .venv/ — the project’s virtual environment. uv creates and manages this for you. Treat it as disposable — deleting it and running uv sync should reproduce the same environment exactly. It is not committed to git.

Dependency declarations

  • Dependencies — declared in [project] dependencies = [...] in pyproject.toml. These are what your users need when they install your package.
  • Optional dependencies (extras) — declared in [project.optional-dependencies]. These are advertised to your users — they install them with pip install yourpkg[plot]. Use these for genuine optional features (e.g. pandas[excel]).
  • Dependency groups — declared in [dependency-groups], standardized in PEP 735. These are local-only: they’re never published to PyPI. Use these for development tools (pytest, ruff, mypy). The dev group is special — uv sync and uv run install it by default.
  • The distinction matters: extras are part of your package’s public API, groups are internal to your repo. Mixing them up is a common mistake (Section 7).

Resolution

  • Resolver — the algorithm that picks compatible versions of every package. uv uses PubGrub, the same SAT-based algorithm used by Dart and Cargo, ported to Rust. It either finds a complete solution or proves none exists — no give-up-after-N-tries.
  • Universal lockfile / forking resolveruv.lock is cross-platform. The resolver evaluates dependencies for all platform markers simultaneously, “forking” when requirements differ between Python versions or OSes (e.g. one numpy version for Python 3.10, another for 3.11+). Both branches end up in the lockfile, tagged with their markers. At install time, only the matching one is installed.
  • Markers — boolean expressions on environment attributes like python_version, sys_platform, platform_machine. Used in dependency specifiers (numpy>=2; python_version >= "3.11") and in lockfile entries to indicate when an entry applies.
  • Wheel tags — encoded in wheel filenames (torch-2.4.0-cp312-cp312-manylinux2014_aarch64.whl). Tell you which Python implementation/version, ABI, OS, and architecture a wheel works on. The lockfile is universal across markers but not across wheel tags — if torch doesn’t ship a wheel for your platform, no resolver magic saves you.

Sources and indexes

  • Index — a package registry (PyPI by default, or a private one). Declared in [[tool.uv.index]].
  • Source override[tool.uv.sources] lets you say “this dependency, even though it’s named in [project] dependencies, comes from here instead of PyPI” — a Git URL, a local path, a workspace member, a specific index. Source overrides are a uv-specific extension; they’re stripped out when you publish your package.
  • Global cache~/.cache/uv (or $XDG_CACHE_HOME/uv). Holds downloaded wheels, built wheels, source archives, and managed Python interpreters. Deduplicates across all your projects.
  • Link mode — how files get from the cache into your .venv. uv defaults to:
    • clone (Copy-on-Write) on macOS and Linux with modern filesystems (APFS, Btrfs, XFS reflinks). Costs zero disk space until a file is modified.
    • hardlink on Windows. Same inode, zero extra space, but the file in the venv is the file in the cache — modifying it corrupts the cache.
    • copy as a fallback when neither works (e.g. cache and venv are on different filesystems).
  • Wheel vs sdist — a .whl file is a pre-built, ready-to-extract archive. An .sdist (.tar.gz) is source code that must be built into a wheel before installation. Wheels are fast, sdists are slow and require build tools.

Workspaces

  • Workspace — a monorepo of multiple Python packages sharing one uv.lock. Borrowed conceptually from Cargo. Each member has its own pyproject.toml; the workspace root has a [tool.uv.workspace] members = [...] section listing them. uv lock resolves the entire workspace as one unit, which is how it can guarantee internal consistency.

Tools and scripts

  • uvx — alias for uv tool run. Runs a CLI tool (like ruff or black) in an ephemeral isolated environment. Replaces pipx run.
  • uv tool install — like pipx install: creates a persistent isolated environment and adds the tool’s executable to your PATH.
  • PEP 723 inline metadata — a TOML block in a Python comment (# /// script ... # ///) that declares dependencies inside a single-file script. uv run script.py reads it, builds an ephemeral env, runs the script.

Keep coming back to this section. Section 4 onwards uses every term defined here.


4. The Distilled Introduction

This section gets you from zero to productive. It covers what a 10-hour video tutorial would, minus the padding.

Installing uv

The recommended path: a self-contained binary, no Python required.

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

# Or via Homebrew, winget, scoop, etc.
brew install uv

That’s it. uv is now on your PATH. It can update itself with uv self update. It does not need Python installed; it will install Python for you on first use.

You can pip install uv into an existing Python, but this defeats the point — you need Python to install the thing that manages Python. The standalone installer is the better path.

Initializing a project

uv init my-project
cd my-project

This creates pyproject.toml, a README.md, a .python-version file, and a main.py stub. It does not create the venv or install anything yet — that’s lazy until you actually need it.

There are flavors:

  • uv init --lib for a library you’ll publish (creates a proper src/ layout and a [build-system] section).
  • uv init --app for an application (the default).
  • uv init --package for an application that’s also a proper installable package — useful when you want uv run to install your code as editable.

Adding dependencies

uv add requests
uv add 'fastapi>=0.110' 'pydantic>=2'
uv add --dev pytest ruff mypy
uv add --group docs sphinx furo
uv add 'httpx @ git+https://github.com/encode/httpx'
uv add ./local-package --editable

Every uv add does four things atomically:

  1. Updates pyproject.toml with the new dependency.
  2. Re-resolves the full dependency graph (writes uv.lock).
  3. Creates .venv/ if it doesn’t exist (using the Python from .python-version, which it’ll install if missing).
  4. Installs the resolved packages into the venv.

You see all four happen in milliseconds. The first uv add in a project is slower because it’s downloading Python and the wheels; subsequent ones hit the global cache and complete in tens of milliseconds.

Removing dependencies

uv remove requests

Removes from pyproject.toml, re-resolves, prunes the venv. Transitive dependencies that no longer have a parent are removed.

Running things

This is where uv diverges from Poetry-style workflows. You do not source .venv/bin/activate. You use uv run:

uv run python main.py
uv run pytest
uv run -- python -c 'import fastapi; print(fastapi.__version__)'

uv run does something subtle and important: before every invocation, it verifies that the lockfile is consistent with pyproject.toml and that .venv matches the lockfile. If anything’s stale, it syncs first, then runs. This means you can git pull a branch with a different pyproject.toml, run uv run pytest, and the environment is reconciled automatically. No “did I forget to install the new dep” questions.

The flip side: uv run is implicit magic. If you want explicit control, use uv sync to materialize the environment, then activate normally:

uv sync
source .venv/bin/activate    # then run python, pytest, etc. directly

Both workflows are valid. uv run for daily work; explicit activation when you’re debugging an environment issue or when external tooling (like an IDE’s debugger) needs the venv active.

Locking and syncing

uv lock                  # re-resolve, update uv.lock, don't touch venv
uv lock --check          # verify lockfile matches pyproject.toml; nonzero exit if not
uv sync                  # make .venv match uv.lock
uv sync --locked         # like sync, but error if lockfile is stale (CI default)
uv sync --frozen         # use lockfile as-is, never re-resolve (fast CI default)

The CI rule of thumb: use uv sync --locked --no-dev (or --no-default-groups if you have multiple) for production builds, and fail the build if the lockfile is out of date. This is the contract that prevents drift.

Managing Python itself

uv python install 3.12 3.13                 # download CPython 3.12 and 3.13
uv python list                              # see installed and available versions
uv python pin 3.12                          # write 3.12 to .python-version
uv venv --python 3.11                       # one-off venv with a specific Python
uv run --python pypy@3.10 -- python --version

Where does Python come from? uv downloads from python-build-standalone — Gregory Szorc’s project that produces relocatable, statically-linked CPython builds. (Astral has now adopted python-build-standalone formally.) These are not the python.org binaries; they’re a separate, well-maintained distribution. For 99% of users this is invisible and great. For organizations with strict supply-chain requirements, it’s worth knowing — see Section 7.

uv will install the right Python version automatically when you run a command. cd into a project with .python-version: 3.13, run uv run python --version, and uv will silently download 3.13 if it’s not on disk. This is the “I love this” moment people have with uv.

Running tools (replacing pipx)

uvx ruff check .                  # ephemeral: download ruff, run it, cache for next time
uvx --from 'ruff==0.6.9' ruff check .   # pin a specific version
uv tool install ruff              # persistent: ruff joins your PATH
uv tool list
uv tool upgrade --all

uvx is the “I just want to run this CLI tool once” path. Each tool gets its own isolated environment in ~/.local/share/uv/tools — they never conflict with each other or with your project.

Running standalone scripts (PEP 723)

# hello.py
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "requests",
#     "rich",
# ]
# ///

import requests
from rich import print
print(requests.get("https://api.github.com/zen").text)
uv run hello.py

uv reads the inline metadata, builds an ephemeral env with requests and rich, and runs the script. First run takes a second; subsequent runs are nearly instant because the env is cached.

You can scaffold the metadata block automatically:

uv init --script hello.py --python 3.12
uv add --script hello.py requests rich

This is genuinely transformative for utility scripts and one-off automation. You can hand someone a single .py file and tell them “you need uv installed; just run uv run thisfile.py.” No requirements.txt, no virtualenv setup, no README full of activation instructions.

Building and publishing

uv build              # produces dist/<package>-<version>.tar.gz and .whl
uv publish            # uploads to PyPI (configure auth via env vars or keyring)

uv build is a frontend — it invokes whatever build backend your pyproject.toml declares (hatchling, setuptools, flit, uv_build, etc.). The default for new projects scaffolded with uv init --lib is uv_build, Astral’s own minimal build backend, which is fast and good enough for most pure-Python packages.

The pip-compatible interface

uv pip install requests
uv pip compile requirements.in -o requirements.txt
uv pip sync requirements.txt
uv pip freeze

This is the migration story. If you have an existing pip/pip-tools workflow, replace pip with uv pip and you get a 10-100x speedup with no other changes. Note that uv pip install does not update pyproject.toml — it’s purely the lower-level “install this into the active venv” interface. Most teams use uv pip for the migration period and then move to the project workflow (uv add, uv sync).

This split — uv pip (low-level, pip-compatible) vs uv add/uv sync (high-level, project-aware) — is the single most common source of confusion for new users. We come back to it in Section 7.


5. The Mental Model

If you internalize these four ideas, almost every behavior of uv becomes predictable.

Core Idea 1: pyproject.toml is the input; uv.lock is the output; .venv is a derivative.

The state of your project lives in two files. pyproject.toml says what you want — broad version ranges, dependency groups, tool config. uv.lock says what you got — exact versions, exact hashes, exact wheels for every platform. Everything else is derived. The .venv is a materialization of the lockfile for your current platform. You can blow it away at any time and uv sync will rebuild it identically.

This predicts:

  • The venv is disposable. rm -rf .venv && uv sync is a routine debugging step, not a panic move.
  • uv.lock is committed to git. .venv/ is not.
  • When pyproject.toml changes, uv.lock becomes stale. uv lock --check fails. CI should enforce this.
  • You should almost never edit uv.lock by hand, but you can read it — it’s TOML, it’s human-readable, and grepping it is a legitimate debugging move.
  • “It works on my machine” is mostly designed out: same pyproject.toml + same uv.lock + same --locked install = same environment, modulo platform-specific wheels.

Core Idea 2: The global cache is the heart; environments are projections of it.

Most package managers think of installation as copying files. uv thinks of it as projecting the cache into a directory. The wheel for numpy==2.1.0 exists once in ~/.cache/uv/wheels/. When you install it into .venv, uv makes a CoW clone (or hardlink, or — fallback — copy) of those files. Across ten projects all using numpy==2.1.0, you have one set of bytes on disk, accessed through ten paths.

This predicts:

  • Creating a venv with the same dependencies as another venv is nearly free. Milliseconds, even for hundreds of packages.
  • A “warm cache” uv sync of a 200-package project takes ~200ms. A cold one takes seconds. CI caching strategy is “preserve ~/.cache/uv between runs, key on uv.lock.”
  • Moving the cache to a different filesystem from the venv (e.g. cache on /, venv on a mounted volume) breaks the linking and falls back to copy. You’ll see the warning: Failed to hardlink files; falling back to full copy. Fix: export UV_CACHE_DIR=... to put the cache on the same filesystem.
  • Never modify files in the cache directly. If you pip install -e . into a venv that hardlinks to the cache, then edit one of the package files, you’ve corrupted the cache for every other project. (CoW mode is safe — it copies on first write.)
  • uv cache clean is safe but can be expensive: every venv that hardlinks (Windows default) becomes broken because the inodes go away. CoW venvs survive.

Core Idea 3: One lockfile, all platforms — because the resolver thinks symbolically about markers.

Traditional resolvers think about one environment: my Python, my OS, my arch. uv’s resolver thinks about all environments at once. When it sees a dependency that branches based on a marker — numpy>=2 ; python_version >= "3.11" and numpy<2 ; python_version < "3.11" — it doesn’t pick one and discard the other. It forks the resolution and keeps both, marker-tagged, in the lockfile. At install time on a real machine, only the marker-matching entries get installed.

This predicts:

  • uv.lock works on macOS, Linux, and Windows from the same file. No “lockfile per platform.”
  • The lockfile can contain multiple versions of the same package (e.g. numpy==1.26.4 for Python <3.11 and numpy==2.1.0 for Python >=3.11). This is correct, not a bug.
  • The resolver can produce a lockfile that no single environment satisfies — that’s the entire point. Each environment is a slice of the lockfile, not the whole thing.
  • However: wheel availability is not marker-driven. If torch doesn’t ship a wheel for ARM Linux and there’s no sdist, no resolver heroics can save you on that platform. You’ll hit the wheel-tag boundary.
  • Locking on x86 and installing on ARM works for most pure-Python and well-supported C-extension packages, but for packages with sparse platform coverage, you’ll want to lock on a machine with similar capabilities or use tool.uv.required-environments to specify the target.

Core Idea 4: uv is a single binary with no Python runtime overhead.

Every other Python tool — pip, Poetry, pyenv — has to start Python before it can do anything. pip install requests spends 100-300ms loading Python and importing pip’s modules before it begins working. uv is a Rust binary; it starts in single-digit milliseconds and parallelizes I/O across all your cores from the first instruction.

This predicts:

  • Every uv command feels effectively instantaneous when nothing is downloading. uv run adds maybe 10-30ms of overhead vs. running Python directly. You can put uv run pytest in a tight feedback loop without complaint.
  • Cold installs are bandwidth-bound, not CPU-bound. Once you’re downloading wheels, you’re at the mercy of PyPI. Hot installs are cache-bound.
  • uv doesn’t need a “uv environment” to run from. There’s no bootstrapping circular dependency. This is why it can install Python — it has no dependency on Python existing.
  • Bugs in uv look different from bugs in pip. Where pip might silently misbehave, uv tends to fail loudly with a structured error. Where pip might take five minutes to produce a confusing trace, uv produces a clear error in milliseconds.

6. The Architecture in Plain English

What actually happens when you type uv add requests?

Step 1: Discovery. uv finds the project root by walking up from the current directory looking for a pyproject.toml. It reads pyproject.toml, then merges configuration from (in priority order) CLI flags, UV_* environment variables, [tool.uv] in pyproject.toml, uv.toml in the project, and ~/.config/uv/uv.toml for user-global settings.

Step 2: Python interpreter resolution. uv reads .python-version (or requires-python from pyproject.toml), then searches in this order: managed Pythons in ~/.local/share/uv/python, system Pythons on PATH, Windows Registry entries, and (if needed and allowed) downloads a managed Python from python-build-standalone. It picks the highest version that satisfies the constraint.

Step 3: Resolution. This is where most of the engineering goes. uv builds a virtual root package containing your direct dependencies plus the new one (requests). It hands this to the PubGrub resolver, which runs on a dedicated thread and:

  • Picks the highest-priority undecided package (URLs > exact pins > tight ranges > loose ranges; ties broken deterministically by file order).
  • Picks a candidate version (highest compatible by default).
  • Asks an async metadata fetcher for that version’s dependencies. Multiple metadata requests run in parallel via Tokio.
  • Adds those dependencies to the partial solution. If contradictions arise, PubGrub generates a human-readable conflict explanation (this is where its name comes from — it’s better at explaining “why” than its predecessors) and backtracks.
  • When the resolver encounters dependencies that branch on environment markers (e.g. one version for Linux, another for Windows), it forks the resolution and resolves each branch independently. Forks can be nested.
  • The metadata it fetches is itself cached in a binary, zero-copy-deserializable format (.rkyv files) so subsequent resolutions are O(1) for known packages.

Step 4: Lockfile generation. All forks are merged into a single uv.lock file. Each package entry includes the source URL, the SHA-256 hash, the wheel filenames available, and (if it came from a fork) the markers that select it. Identical entries across forks are deduplicated.

Step 5: Environment sync. uv compares the lockfile to the current .venv. For each package that needs to be added:

  • Check the global cache. Is the wheel already there?
  • If not, download it (in parallel, async). Store it in the cache. If it’s an sdist, build it into a wheel (in an isolated build environment using PEP 517) and cache the result.
  • “Install” the wheel into the venv. This means: extract the contents, then for each file, attempt to clone (CoW) or hardlink from the cache into the venv’s site-packages. Fall back to copy if the link fails.
  • Write RECORD and INSTALLER metadata files so the venv’s installation looks legitimate to standard Python tooling.

Step 6: Optional bytecode compilation. If --compile-bytecode is passed (or UV_COMPILE_BYTECODE=1 is set, common in Docker images), uv compiles .pyc files for everything it just installed.

The whole sequence, on a warm cache, takes 50-200ms for a typical web app project. Cold, with a Python download and a few hundred packages, maybe 5-15 seconds.

Where state lives:

  • Project state: pyproject.toml, uv.lock, .python-version (committed).
  • Local environment: .venv/ (gitignored, disposable).
  • Global cache: ~/.cache/uv/ (deduplicated wheels and sdists).
  • Managed Pythons: ~/.local/share/uv/python/ (each version is a self-contained directory).
  • Tool environments: ~/.local/share/uv/tools/ (one venv per uv tool install-ed tool).
  • Tool executables: ~/.local/bin/ (the actual binaries on your PATH; they’re scripts that activate the corresponding tool env).

The mental picture is two filesystems-of-data: the project, which is small and committed; and the user-global cache, which is large and shared across all your work. The venv is the join point.


7. The Things That Bite You

Gotcha 1: uv add vs uv pip install

This is the most common confusion for people coming from pip. They run uv pip install requests, see it work in milliseconds, and think they’re done. They commit and push. CI fails: requests is not in pyproject.toml, uv.lock doesn’t know about it.

uv pip install is the low-level, pip-compatible interface. It installs a package into the active venv, period. It does not update pyproject.toml, does not update uv.lock, does not touch your project metadata. It exists for migration and for cases where you genuinely don’t have a project (e.g. a notebook environment).

uv add is the project-level interface. It updates everything atomically.

Rule: if you have a pyproject.toml, always use uv add. Use uv pip only for migration scripts or no-project workflows. (This connects directly to Mental Model 1: pyproject.toml is the source of truth. uv pip install bypasses it.)

Gotcha 2: optional-dependencies vs dependency-groups

These look similar in pyproject.toml. Both are TOML tables, both list packages, both can be selected at install time. They are not the same thing.

[project.optional-dependencies]   # PUBLISHED. Users see these.
plot = ["matplotlib"]
excel = ["openpyxl"]

[dependency-groups]                # LOCAL ONLY. Never published.
dev = ["pytest", "ruff"]
docs = ["sphinx"]

Optional dependencies are advertised — when someone installs your library, they can opt into them with pip install yourlib[plot]. They’re part of your package’s public surface.

Dependency groups (PEP 735) are private to your repo. They’re for development tooling. They never appear in your built wheel. When someone runs pip install yourlib, they will never get your dev tooling, even if they wanted to.

Rule: use optional-dependencies for things your users might want. Use dependency-groups for things only contributors need. When in doubt, it’s a group, not an extra.

If you’re building an application (not a library), this still matters: extras propagate when your app is installed somewhere else, groups don’t. For pure apps that aren’t meant to be installed, both work, but groups are more semantically honest.

This warning means uv couldn’t link from the cache into your venv. The link failure is not catastrophic — installation still works, just slower and with more disk usage. But it indicates a real misconfiguration.

The cause is always the same: cache and venv are on different filesystems. Common scenarios:

  • Cache on the host (~/.cache/uv), venv inside a Docker container (different filesystem).
  • Cache on a network mount, venv on local disk.
  • On Windows, uv cache dir returns %LOCALAPPDATA%\uv\cache, but your project lives on a different drive.
  • The filesystem doesn’t support hardlinks (very old FAT32, some FUSE mounts).

Fix: set UV_CACHE_DIR or --cache-dir to a path on the same filesystem as your project. In Docker, this typically means a mounted cache volume. If you genuinely want to copy, set UV_LINK_MODE=copy to silence the warning.

Gotcha 4: uv sync removes packages you didn’t install through uv

uv sync does exact syncing by default. If your venv has pip install-ed something that isn’t in the lockfile, uv sync will uninstall it. This is correct behavior — the venv is supposed to be a projection of the lockfile — but it surprises people who use uv pip install for ad-hoc installs.

Counterintuitive but important: uv run does not do exact syncing. It uses “inexact” mode: ensures everything required is present, but leaves extraneous packages alone. So:

  • uv run pytest — installs missing deps, keeps your ad-hoc pdb++.
  • uv sync — installs missing deps, removes your ad-hoc pdb++.

If you want uv sync to be tolerant, use --inexact. If you want predictable production environments, embrace the exact default and add anything you depend on to the lockfile.

Gotcha 5: The lockfile only invalidates on constraint changes, not version availability

If you have requests>=2.30 in pyproject.toml, and uv.lock pinned requests==2.31.0, and requests==2.32.0 releases tomorrow, uv lock --check will still pass. The lockfile is “up to date” because 2.31.0 still satisfies >=2.30. uv will not auto-upgrade.

You explicitly upgrade with:

uv lock --upgrade                    # try to upgrade everything
uv lock --upgrade-package requests   # just one package

This is intentional and good — it means git pull && uv sync doesn’t silently change your dependency versions. But it does mean you need a deliberate upgrade cadence. (Connects to Mental Model 1: uv.lock is the source of truth for what’s installed; upgrades are an explicit action.)

Gotcha 6: uv build does not run inside your project venv

When you run uv build, uv creates a separate build environment with the build backend (e.g. hatchling) and only the build dependencies declared in [build-system] requires. Your project’s runtime dependencies are not present. This is correct per PEP 517, but it confuses people who expect uv build to use the venv they’ve been working in.

If your build process needs runtime deps (some packages do; flash-attention is the canonical example), use --no-build-isolation and ensure those deps are available another way. This is an advanced escape hatch — most projects don’t need it.

Gotcha 7: Universal lockfile ≠ universal wheel availability

The forking resolver makes uv.lock work across all platforms in the resolution sense: it knows what version of each package each platform wants. But if a package only ships wheels for x86_64 Linux and you uv sync on ARM Mac, you’re going to get an error: no wheel for your platform, and (often) no sdist either.

torch is the canonical example. PyTorch maintains separate indexes for different CUDA versions, and not every wheel is built for every platform. If you naively uv add torch, you’ll get the right wheel for your machine but might break someone else’s machine.

Solutions:

  • Use [tool.uv.sources] with index = "..." to pin torch to the right PyTorch index.
  • Use [tool.uv] conflicts = [...] to declare that mutually-exclusive extras (cpu vs gpu) shouldn’t be resolved together.
  • Use [tool.uv] required-environments = [...] to tell uv which platforms must successfully resolve, so it errors loudly at lock time instead of silently producing a lockfile that breaks on production.

Gotcha 8: Managed Python interpreters are not python.org’s binaries

uv python install 3.12 does not download from python.org. It downloads from python-build-standalone, a project producing relocatable, statically-linked CPython builds. They’re well-maintained, signed, and now formally adopted by Astral. But they have minor behavioral differences:

  • They link against an older glibc (for portability), so ctypes-using packages occasionally have linker quirks.
  • They include their own OpenSSL build, which may differ from your system’s.
  • The directory layout is slightly different (everything is in one prefix; no system-wide install).

For 99% of use cases this is invisible and fine. For organizations that have policies requiring official python.org binaries, you’ll need to point uv at a system-installed Python with --python /usr/bin/python3.12 or the UV_PYTHON_INSTALL_MIRROR env var (for an internal mirror).

Gotcha 9: uvx does not always do what you’d expect for tool runtime dependencies

uvx ruff check . runs ruff in an environment containing… ruff. If you have a tool that needs your project’s deps to function (some pytest plugins, some coverage tools), running it via uvx won’t work — uvx doesn’t know about your project at all.

For tools that need your project’s environment, use uv run:

uv run pytest                # runs pytest with your project's deps available
uvx pytest                   # runs an isolated pytest with NONE of your deps — almost never what you want

Rule of thumb: if the tool reads or imports your code, use uv run (after adding it as a dev dependency). If the tool just operates on files (ruff, black), uvx is fine but uv run works too.

Gotcha 10: Editable installs and [build-system]

If you use uv run in a project without a [build-system] section in pyproject.toml, your project’s own code won’t be installed as editable in the venv — only its dependencies will. from my_project import foo will fail.

Adding [build-system] (e.g. requires = ["hatchling"]; build-backend = "hatchling.build") makes uv treat the directory as a package and install it editable. This is what uv init --lib and uv init --package do automatically.

If you started with uv init (which creates an application, no [build-system]) and later need editable install behavior, just add a build system block. No other changes needed.


8. The Judgment Calls

1. Use uv add / uv sync or stick with uv pip?

Use uv add / uv sync for any greenfield project, any project where reproducibility matters, any project with a team. The locked, project-aware workflow is the whole point of uv.

Use uv pip when you’re migrating an existing pip-based project incrementally, when working in a notebook environment without a pyproject.toml, or when integrating with tools that emit requirements.txt (some legacy CI, some Docker base images). Treat uv pip as a tactical compatibility layer, not a destination.

The signal: if there’s a pyproject.toml in the repo, your default move is uv add. If there isn’t and creating one is more disruption than value, uv pip compile && uv pip sync is fine.

2. Single project or workspace?

Single project for the vast majority of cases. One pyproject.toml, one uv.lock, one .venv. Don’t reach for workspaces because they sound nice.

Workspace when you have multiple deployable units that share code and need consistent dependency resolution across all of them. The classic shape: a FastAPI service, a queue worker, and a shared common library, all in one repo. They share a lockfile so the worker and the API can never have conflicting versions of pydantic. They share editable installs so changes to common/ are immediately visible in the others.

The signal: if you find yourself maintaining two pyproject.tomls in the same repo and worrying about keeping their dependency versions in sync, you wanted a workspace. If you have one repo per service and they communicate over the network, you don’t.

Don’t use a workspace for a library plus its examples or its docs. Those should be a single project with extras or groups, not separate workspace members.

3. Managed Python or system Python?

Managed Python (uv python install) by default. Reproducible across machines, fast to install, no system contamination. Your .python-version becomes a portable contract: “this project runs on 3.12, full stop.” CI doesn’t need to set up Python separately. Docker images don’t need a Python base layer.

System Python when you have organizational policies that mandate official builds, when you need a Python compiled with non-default flags (free-threading builds, debug builds, custom OpenSSL), or when you’re working on a system with strict supply-chain controls. Pass --python /path/to/python explicitly.

The signal: ask “if a teammate on a different OS clones this repo, what’s the path to running my code?” If the answer is “install Python 3.x first, then…” — managed Python eliminates that step.

4. dev dependency group or other named groups?

Just dev is fine for small projects. One group, all your tools (pytest, ruff, mypy, pre-commit). It’s auto-installed by uv run and uv sync.

Multiple named groups (test, lint, docs, typing) when CI runs different jobs that need different subsets, or when contributors are split (some only do docs, some only test). The win is in CI: uv sync --only-group test is faster than installing the whole dev umbrella.

The signal: do any of your CI jobs need only a subset of your development tools? If yes, split. If you’re running pytest and ruff in one CI step anyway, don’t bother.

5. Pin dependencies tightly or loosely?

Loose ranges (fastapi>=0.110,<0.120) for libraries you publish. Your users have their own version constraints; over-pinning will cause unsatisfiable resolutions in their projects.

Tight pins or no pins for applications. The lockfile pins the exact version anyway; what pyproject.toml says is just the range you allow on upgrade. fastapi>=0.110 (no upper bound) for applications is fine — it gives uv lock --upgrade room to move.

Avoid upper-bounding requires-python to <4. uv ignores it anyway, and it causes spurious resolution conflicts. Use requires-python = ">=3.11" without an upper bound.

The signal: are you the consumer of these constraints, or someone else? Apps consume their own constraints (be tight). Libraries inflict them on others (be loose).

6. Hardlink/CoW or explicit copy?

Default (clone/hardlink) unless you have a reason. The disk savings and speed are real.

UV_LINK_MODE=copy when:

  • Your venv is on a different filesystem from the cache and you can’t change either (e.g. Docker, NFS-backed home dirs).
  • You’re building a Docker image and want the final layer to be self-contained (a hardlinked venv breaks if the cache directory isn’t preserved into the runtime image).
  • You’re handing the venv to a tool that doesn’t understand hardlinks (rare, but some packagers and AV scanners trip on it).

In multi-stage Docker builds, copy mode in the build stage and --no-installer-metadata is a common pattern to produce a clean, portable venv.

7. CI: cache ~/.cache/uv or rebuild every time?

Cache it for typical CI workloads. Key on uv.lock. The official astral-sh/setup-uv GitHub Action handles this with enable-cache: true. After the run, uv cache prune --ci removes pre-built wheels (which are fast to re-download) but keeps built-from-source wheels (which are expensive to rebuild). This trims cache size considerably.

Don’t cache in scenarios where reproducibility is the explicit goal — e.g. release builds where you want to verify that uv sync --locked works from scratch. A “fresh build” job once a day in CI catches drift that the cached jobs miss.

8. Lock with which Python and which platform?

uv.lock is universal across markers, but the resolution strategy defaults to the locking machine’s Python version and platform for picking which wheels actually get listed. For a team that ships to Linux but locks on macOS, this usually works fine — wheels for both platforms are listed in the lockfile. But for packages with sparse platform coverage (the eternal torch example), lock on the platform that most exercises the constraint, or use tool.uv.required-environments to assert “this lockfile must work for these targets.”

Rule: if your CI is Linux x86_64 and your devs are macOS arm64 and Windows x86_64, configure required-environments for all three. The lock will fail loudly if any of them can’t be satisfied, instead of silently producing a lockfile that breaks for one of them.

9. uv build with which backend?

uv_build (Astral’s own) for new pure-Python libraries scaffolded by uv init --lib. Fast, minimal, designed to work seamlessly with uv.

hatchling when you need plugins (e.g. for build-time version detection from VCS), or when your team is already standardized on it. It’s the most popular modern backend and has the broadest plugin ecosystem.

setuptools when you have native code, complex build steps, or a legacy setup.py you can’t easily migrate. Setuptools is still the only backend with a fully-fleshed C extension build system.

maturin for projects with Rust extensions. scikit-build-core for projects with CMake-based C/C++ extensions.

The signal: pure Python with no special needs → uv_build or hatchling. Anything else → pick the backend that solves your specific compile/build problem.

10. Migrate from Poetry now, or wait?

Migrate if your Poetry pain points are real: slow resolution on a complex graph, Poetry breaking on Python upgrades, your CI being dominated by env setup. The migration is mechanical: poetry export to requirements.txt, uv add -r requirements.txt, then commit pyproject.toml and uv.lock. Or use uvx poetry-to-uv (or similar tools that have emerged).

Wait if your project is library-focused and you make heavy use of Poetry-specific features (its publishing, plugins, or particular dependency-group semantics from before PEP 735 was finalized). Poetry 2.x adopted PEP 621, narrowing the gap. The migration is real work; only do it if there’s payoff.

The signal: are you spending more time fighting your tooling than working on your code? If yes, the migration cost is small relative to the ongoing cost. If your Poetry setup is humming along, “newer is better” is not a reason to switch.


9. The Commands That Actually Matter

These are the commands you will actually use, grouped by task. The full CLI is much larger; this is the 80%.

Project lifecycle

uv init                        # new app
uv init --lib                  # new library (with src/ layout, build system)
uv init --package              # new app that's also a package
uv init --script foo.py        # new PEP 723 script

uv add <pkg>                   # add dependency
uv add --dev <pkg>             # add to dev group
uv add --group <name> <pkg>    # add to a custom group
uv add --optional <name> <pkg> # add as an extra
uv remove <pkg>                # remove (cleans transitive)

Lockfile and environment

uv lock                        # re-resolve, update uv.lock
uv lock --upgrade              # resolve to latest within constraints
uv lock --upgrade-package <p>  # upgrade just one
uv lock --check                # CI: fail if lockfile is stale

uv sync                        # exact sync: venv = lockfile
uv sync --locked               # CI: error if lockfile is stale
uv sync --frozen               # use lockfile as-is, never re-resolve
uv sync --no-dev               # skip dev group
uv sync --only-group test      # CI: install only one group
uv sync --inexact              # don't remove extraneous packages

Running things

uv run <cmd>                   # run with auto-sync
uv run python <file>           # run a Python script in the env
uv run pytest                  # run a tool from your project's env
uv run --no-sync <cmd>         # skip the sync step (faster, less safe)
uv run --with <pkg> <cmd>      # add an ephemeral dep just for this run

Tools (ex-pipx)

uvx <tool> [args]              # ephemeral run; tool not persisted
uvx --from <pkg> <tool>        # tool comes from a different package
uv tool install <pkg>          # persistent install
uv tool list
uv tool uninstall <pkg>
uv tool upgrade --all
uv tool dir --bin              # where the executables live (add to PATH)

Python management

uv python install <ver>        # install a managed CPython
uv python install 3.12 3.13    # multiple at once
uv python list                 # see installed + available
uv python pin <ver>            # write to .python-version
uv python find <ver>           # discover an interpreter (for scripting)
uv python uninstall <ver>

Building and publishing

uv build                       # build sdist + wheel
uv build --wheel               # wheel only
uv build --sdist               # sdist only
uv publish                     # upload to PyPI (configure auth via env)
uv publish --index <name>      # to a different index

Cache management

uv cache dir                   # show cache location
uv cache clean                 # nuke the whole cache
uv cache clean <pkg>           # remove cache for one package
uv cache prune                 # remove stale entries (safe, periodic)
uv cache prune --ci            # CI-optimized: drop pre-built wheels

Pip-compatible interface (migration)

uv pip install <pkg>           # like pip install
uv pip uninstall <pkg>
uv pip list
uv pip freeze
uv pip compile reqs.in -o reqs.txt   # like pip-compile
uv pip sync reqs.txt           # ensure venv matches reqs.txt exactly

Workspace operations

uv sync --package <member>     # sync a specific member's env
uv run --package <member> <c>  # run within a specific member
uv lock                        # always operates on the whole workspace

Useful environment variables

VariableWhat it controls
UV_CACHE_DIRwhere the cache lives
UV_LINK_MODEclone, hardlink, copy, symlink
UV_PYTHONwhich Python to use (overrides .python-version)
UV_PROJECT_ENVIRONMENTwhere to put the venv (default .venv)
UV_COMPILE_BYTECODEcompile .pyc after install (1 for prod)
UV_NO_SYNCskip the implicit sync in uv run
UV_INDEX / UV_EXTRA_INDEX_URLconfigure registries
UV_PYTHON_INSTALL_MIRRORprivate mirror for python-build-standalone
UV_PUBLISH_TOKENauth for uv publish

10. How It Breaks

Failure: “No solution found when resolving dependencies”

Symptom: uv lock fails with a multi-paragraph “because A requires B but C requires D, …” explanation.

Root cause: Your pyproject.toml has constraints that genuinely can’t be satisfied. PubGrub is good at proving this — when it says no solution exists, you should believe it.

Diagnosis: Read the explanation top to bottom. PubGrub’s error is a conflict tree — it tells you the chain of requirements that lead to contradiction. Look for the leaves: which two packages have constraints that can’t both be satisfied?

Fix: Either relax one of the constraints (often by removing an upper bound), pick a compatible version, or add an override in [tool.uv] if you need to forcibly pin a transitive dep.

Failure: “No matching distribution found for X”

Symptom: Resolution succeeds but installation fails: package X has no wheel for your platform and (usually) no sdist either, or the sdist requires build tools you don’t have.

Root cause: Wheel-tag boundary, not a resolver problem. Connects to Mental Model 3.

Diagnosis: Check PyPI for the package — what platforms are wheels published for? uv pip download <pkg> --only-binary :all: --platform <yours> will show you.

Fix options:

  • Use a different version that has wheels for your platform.
  • Use [tool.uv.sources] with a different index that has the wheel (PyTorch’s pattern).
  • Install build tools (gcc, make, Python dev headers) and let it build from sdist.
  • Use a different package.

Failure: Lockfile is stale; CI breaks with --locked

Symptom: Local uv.lock doesn’t match pyproject.toml. CI’s uv sync --locked errors out.

Root cause: Someone changed pyproject.toml (added a dep, changed a constraint) and didn’t run uv lock, or didn’t commit the resulting uv.lock.

Diagnosis: uv lock --check locally will tell you what’s stale.

Fix: Run uv lock, commit both files. Make this a pre-commit hook. Make CI fail loudly on this rather than papering over it with --frozen.

Already covered in Gotcha 3. Set UV_CACHE_DIR to the same filesystem as your venv, or set UV_LINK_MODE=copy.

Failure: uv run is unexpectedly slow

Symptom: uv run something takes 5+ seconds before doing anything.

Root cause: Implicit sync is doing real work. Possibilities:

  • pyproject.toml changed and the lockfile is being regenerated.
  • The lockfile changed (e.g. after git pull) and the venv is being updated.
  • The cache is cold (just cleared, or you’re on a new machine).

Diagnosis: Run uv sync explicitly to see what work it does. Or use uv run -v for verbose output.

Fix: None usually needed — once synced, subsequent runs are instant. If you’re in a tight inner loop and don’t care about staleness, use uv run --no-sync.

Failure: ImportError after uv add

Symptom: uv add foo succeeds, but uv run python -c 'import foo' fails with ImportError or with “No module named foo”.

Root cause options:

  • The package’s import name doesn’t match its distribution name (e.g. you uv add Pillow but you import PIL). Check PyPI for the actual import name.
  • You’re not running through uv run and the venv isn’t activated.
  • You’re in a different project directory than you think.

Diagnosis: uv pip list shows what’s actually installed. which python (after activating) shows which Python is in use.

Failure: A previously-working uv sync now does big surprising work

Symptom: uv sync rebuilds many packages, takes much longer than usual.

Root cause: Cache invalidation. uv bumped a cache bucket version (rare but happens with breaking changes — e.g. uv 0.4.13 bumped the metadata bucket from v12 to v13). Or you upgraded Python and now need wheels for the new ABI.

Fix: Let it run. It’s correct. After it finishes, uv cache prune will clean up the old version’s leftovers.

General debugging workflow

When something is wrong and you’re not sure what:

  1. uv lock --check — is the lockfile current?
  2. uv pip list — what’s actually installed?
  3. uv pip tree — what’s the dependency graph in this venv?
  4. uv cache dir and check that it’s on the same filesystem as .venv.
  5. rm -rf .venv && uv sync — the nuclear option that works surprisingly often. The venv is disposable; rebuilding it from uv.lock is fast.
  6. uv -v <command> or uv -vv <command> for progressively more verbose output. This shows exactly which interpreter was discovered, which cache entries hit, which were missed.

11. The Taste Test

What does a well-uv-shaped project look like, and what gives away inexperience?

Bad pyproject.toml — beginner engineer

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11,<4"
dependencies = [
    "fastapi==0.115.0",
    "pydantic==2.7.4",
    "sqlalchemy==2.0.30",
    "pytest==8.2.0",
    "ruff==0.6.0",
    "black==24.4.2",
    "mypy==1.10.0",
]

[project.optional-dependencies]
dev = [
    "pytest==8.2.0",
    "ruff==0.6.0",
    "black==24.4.2",
    "mypy==1.10.0",
]

Tells:

  • <4 upper bound on requires-python. uv ignores it, but it signals not knowing why it’s pointless.
  • Pinning every direct dependency to an exact version in pyproject.toml. The lockfile already does this; in pyproject.toml it just blocks future upgrades.
  • Dev tools in main dependencies. They’ll be installed for users.
  • Same dev tools also in optional-dependencies.dev — duplicated, and using extras instead of dependency-groups means they get published as part of the package metadata.
  • Both ruff and black. Ruff’s formatter does what black does, faster. Pick one.

Good pyproject.toml — experienced engineer

[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115",
    "pydantic>=2.7",
    "sqlalchemy>=2.0",
]

[dependency-groups]
dev = [
    "pytest>=8.2",
    "pytest-cov>=5",
    "ruff>=0.6",
    "mypy>=1.10",
]

[tool.uv]
required-environments = [
    "sys_platform == 'linux' and platform_machine == 'x86_64'",
    "sys_platform == 'darwin' and platform_machine == 'arm64'",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Tells:

  • Loose lower bounds on direct deps. The lockfile pins the actuals.
  • No upper bound on requires-python.
  • Dev tools in [dependency-groups], not in main deps and not in extras.
  • One formatter (ruff handles both lint and format).
  • required-environments declares what platforms must work — CI will fail loud if a lockfile can’t satisfy them.
  • Build system declared, so uv treats this as a proper package and uv run installs it editable.

Bad workflow — beginner

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install pytest
python -m pytest
pip freeze > requirements.txt

What’s wrong: manual venv, no lockfile (frozen output is platform-specific), tooling installed ad-hoc and not declared, pip freeze includes transitive packages alongside direct ones, no way to reproduce on another machine.

Good workflow — experienced

git clone repo && cd repo
uv run pytest

That’s it. uv run syncs the env from uv.lock, runs pytest. New contributor productive in 10 seconds.

Bad Dockerfile — beginner

FROM python:3.12
COPY . /app
WORKDIR /app
RUN pip install uv && uv pip install -r requirements.txt
CMD ["python", "main.py"]

What’s wrong: copies everything before installing (so any code change busts the install layer cache); uses pip install uv (Python overhead); no multi-stage; ships uv and build tools to production; no bytecode compilation; doesn’t use the lockfile.

Good Dockerfile — experienced

# syntax=docker/dockerfile:1.7

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy

# Install deps first, separately, for layer cache reuse
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-install-project --no-dev

# Now copy source and install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

FROM python:3.12-slim-bookworm
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "main.py"]

What’s right: pre-built uv image avoids needing to install it; layer cache is structured so source code changes don’t bust dep installation; --no-install-project lets deps install before source is even copied; cache mount preserves ~/.cache/uv across builds; UV_LINK_MODE=copy because cache and venv are on different layer filesystems; bytecode compiled for production startup time; --no-dev skips test tooling; final stage doesn’t have uv or build tools, just Python and the venv.

Bad commit log — beginner

fix
update reqs
fix tests
add some packages

Lockfile updates mixed with feature work, no traceability of why a version moved.

Good commit log — experienced

deps: bump fastapi from 0.115 to 0.118 for security patch
deps: add pytest-asyncio to dev group
feat: add /healthz endpoint
chore: regenerate uv.lock after pyproject changes

Lockfile changes are explicit, version bumps are justified, dependency vs feature commits are separated.

Bad CI step — beginner

- run: pip install -r requirements.txt
- run: pytest

No caching, no lockfile, no Python version pinning, no failure on drift.

Good CI step — experienced

- uses: astral-sh/setup-uv@v8
  with:
    enable-cache: true
- run: uv sync --locked --group test
- run: uv run pytest
- run: uv lock --check       # fail if lockfile is stale

Cached, locked, with explicit drift check. The uv lock --check is the line that prevents most “works on my machine” incidents.


12. Where to Go Deeper

Primary, official, opinionated.

  1. The uv documentationhttps://docs.astral.sh/uv/. The “Concepts” section in particular is unusually high-quality for a tool’s own docs; most tools have reference manuals, uv has a philosophy section. Read the “Resolution,” “Caching,” and “Workspaces” pages cover-to-cover at least once. They’re shorter than you’d expect.

  2. Charlie Marsh’s Jane Street talk on uv’s engineering — search “Charlie Marsh uv Jane Street” on YouTube. Forty-five minutes that explain the architectural decisions: why Rust, why PubGrub, why a global cache, why hardlinks. If you only watch one thing about uv, watch this.

  3. The PubGrub paper / blog post — Natalie Weizenbaum’s original write-up of PubGrub for Dart. The algorithm uv uses is a Rust port of this. Reading the algorithm gives you intuition for why uv’s error messages are so good — it’s structurally produced from the conflict tree.

Secondary, practical.

  1. Hynek Schlawack’s “Production-ready Python Docker Containers with uv”https://hynek.me/articles/docker-uv/. The single best deep-dive on Dockerizing uv projects with multi-stage builds, cache mounts, and security-conscious choices. Read it before you write your first uv-based Dockerfile.

  2. The uv-docker-example repohttps://github.com/astral-sh/uv-docker-example. Astral’s official reference Dockerfile patterns, including the standalone-Python variant. Steal liberally.

  3. The uv GitHub issues and discussions — for anything weird, this is where the actual answers live. The Astral team is unusually responsive and the issue threads tend to capture the real reasoning behind design decisions.

For going deeper into Python packaging in general.

  1. PEP 517, PEP 518, PEP 621, PEP 723, PEP 735 — these are the standards uv implements. Reading them clarifies what’s uv-specific vs. standard Python packaging. PEP 723 (inline script metadata) and PEP 735 (dependency groups) are short and beautifully designed; PEP 517/518 are denser but worth understanding once.

  2. Brett Cannon’s “Why I think Python’s pyproject.toml is the future” and his series on Python packaging history — context for why the ecosystem looks the way it does. uv makes more sense once you’ve seen the wreckage it’s built on top of.

Hands-on.

  1. Build something nontrivial in uv from scratch. A FastAPI service with a Postgres connection, a CLI tool, or a data pipeline with PyTorch. The PyTorch one in particular forces you through every advanced feature: indexes, sources, optional dependencies, conflicts, required-environments. By the end you’ll know uv deeply.

  2. Migrate one real project from pip/poetry to uv. Not greenfield — actual migration, with the warts. You’ll hit five gotchas you didn’t expect, learn the migration patterns, and end up with a pyproject.toml structure you’ll use forever.


13. The Downsides

uv is genuinely good. It is also not perfect, and pretending otherwise would do you a disservice. Here are the real tradeoffs, ranked roughly by how much they’re likely to matter to you.

1. It’s young, and that shows in subtle ways

uv had its first release in February 2024 and is still pre-1.0 as of this writing. That has concrete consequences:

  • Breaking changes happen. Not constantly, and the team is careful, but cache format bumps, CLI behavior changes, and config schema changes all occur. If you pin uv itself in CI (which you should), expect to bump the pin every few months and read release notes when you do.
  • The lockfile format is internal. uv.lock is documented as an implementation detail that may evolve. If you want to read it programmatically (e.g. for SBOM generation), Astral’s official answer is “use uv export to a stable format, or use the experimental uv workspace metadata preview command.”
  • Some preview features sit in preview for a long time. Workspace metadata, certain resolver controls, and some build configurations still require --preview-features flags. If your needs intersect with these, you’re on the bleeding edge.

This will all settle over the next year or two. But if you’re choosing tooling for a project that needs to be untouched and unmaintained for five years, “the youngest tool in the ecosystem” is a real consideration.

2. Rust binary, not Python — implications for contribution and customization

uv is written in Rust. Most Python developers cannot meaningfully contribute to it, debug into it, or fork it for an in-house variant. Compare this to pip, which any Python developer can read and patch.

This isn’t a bug — Rust is why uv is fast — but it’s a real shift in who owns the tool. If you hit a bug, you file an issue and wait. You can’t drop into your IDE, set a breakpoint in the resolver, and figure out what’s happening for yourself the way you can with Poetry. For most teams this is a fair trade. For some (companies with strong “we must be able to maintain our toolchain in-house” policies), it’s a blocker.

3. Python interpreters from a non-official source

Covered as Gotcha 8, but worth re-stating as a downside: uv python install pulls from python-build-standalone, not from python.org. These builds are excellent and now formally maintained by Astral, but they are not the canonical CPython binaries. They link against an older glibc, ship their own OpenSSL, and have minor layout differences.

For most users this is invisible. For:

  • Regulated industries with supply-chain compliance requirements,
  • Organizations that audit and re-build their Python from source,
  • Anyone who needs a non-default-build CPython (free-threaded, debug, custom-compiled),

you’ll need to bypass uv’s Python management and point at system interpreters. uv supports this fine, but it means you’ve given up one of the headline features.

4. Astral is a VC-backed company with no obvious revenue model

uv is open source (MIT/Apache-2.0 dual licensed). Astral the company is venture-funded. They make ruff, uv, and ty (a type checker) — all free, all open source. There’s no paid tier, no enterprise edition, no hosted service.

This raises the obvious question: what’s the business model? Astral hasn’t announced one. Reasonable possibilities range from “future hosted PyPI mirror / artifact registry” to “enterprise consulting” to “they figure it out later.” Less reasonable possibilities include “they don’t, and the project loses its corporate maintainer.”

The hedge: uv is open source. If Astral disappeared tomorrow, the code keeps working, and someone could fork it. The lockfile and pyproject.toml formats are open. You’re not locked in the way you’d be with a proprietary tool. But the velocity and quality currently come from a paid team; if that team goes away, the pace of improvement does too.

This is not a reason to avoid uv. It is a reason to keep an eye on Astral’s company news, and to make sure your project’s adoption of uv is reversible (which it is — a uv.lock can be exported to requirements.txt in seconds).

5. The CLI surface is large and growing

This is a genuine criticism that comes up often. uv has commands and subcommands for projects, pip-compatibility, tools, Python management, builds, publishing, caching, and workspaces. It’s a lot. New users frequently can’t tell whether to reach for uv pip install, uv add, uv tool install, or uvx.

Compare to Poetry, where most workflows are 5-6 commands you learn once. Or to pipx, which does one thing.

The flip side is that uv consolidates many tools — so the total surface area you have to learn is smaller than pip + virtualenv + pyenv + pipx + pip-tools + twine. But the cognitive load of “which uv subcommand do I want” is real, especially for newcomers.

This guide’s Section 8 (judgment calls) is partly an attempt to address this — most of the “which command” decisions have clear right answers if you know the question to ask.

6. Universal lockfiles can be huge and slow to merge

uv.lock files for nontrivial projects routinely run into thousands of lines. They contain wheel hashes for every platform/Python combination, fork markers, source URLs, all of it. Two consequences:

  • Git diffs are noisy. Bumping one dependency can change hundreds of lines in uv.lock because hashes get re-fetched and forks get re-laid-out. PR reviewers learn to skip uv.lock diffs entirely.
  • Merge conflicts are painful. Two branches that each uv add something different will produce competing lockfiles. The right answer is almost always “discard both versions, re-run uv lock on the merged pyproject.toml,” but this isn’t obvious to newcomers and tools like GitHub’s auto-resolver can’t help.

Poetry and PDM have the same problem, so this isn’t uv-specific. But if you’re coming from requirements.txt, the lockfile is much heavier.

7. Sources and indexes are powerful but quirky

[tool.uv.sources] is a uv-specific extension. When you publish your package, it gets stripped out — so if your library depends on a Git fork of something, your users don’t get that override. They get the published-PyPI metadata.

In practice this means: tool.uv.sources is for applications and workspaces. For libraries, you generally need to either upstream your changes or vendor the dependency. The first time someone discovers this — usually after publishing a library that worked locally and broke for users — it’s a frustrating lesson.

8. Edge cases around build isolation and native extensions

PEP 517 build isolation is the right default, but some packages are designed to break it. flash-attention is the canonical example, but it’s not alone — anything that needs to compile against a specific PyTorch ABI tends to require --no-build-isolation, which means you have to manually orchestrate which deps are present in the build env. It works, but it’s not the seamless experience uv provides for everything else.

Adjacent: native extensions that build from sdist still need a system C/C++ toolchain. uv makes everything else fast, but if you’re installing from sdist, you’re still bound by setuptools + gcc invocation time. The fix is usually “wait for the package to ship wheels,” not anything uv can do.

9. Ecosystem inertia

A lot of Python infrastructure assumes pip, requirements.txt, and setup.py. CI systems, deployment platforms, security scanners, IDE integrations, internal tooling at companies — most of it speaks pip. uv has good bridges (uv pip, uv export, IDE plugins are emerging), but you’ll periodically run into something that doesn’t natively understand uv.

This is improving fast. PyCharm and VS Code now have first-class uv support. Major CI providers (GitHub Actions, GitLab) have native steps. AWS Lambda, Google Cloud Run, and similar platforms work fine with uv-built artifacts. But for the long tail — internal corporate tooling, less popular CI, niche cloud providers — expect occasional friction.

10. The “magic” can hide problems

uv run does a lot before it runs your command: discover the project, check the lockfile, sync the venv, install Python if missing, then exec your command. When this works, it’s wonderful. When something goes wrong inside that chain, the error can be confusing, and “it just works” gives way to “wait, what just happened?”

This is the cost of automation. Poetry and pip + venv make you do more steps, but you can see where each step happens. uv collapses them, and when the collapse fails, you have to know how to expand it back out (uv lock, uv sync, uv run --no-sync, etc.) to debug.

The tooling is good enough that this is rare. But it does happen, and it’s slightly harder to debug than the manual workflow it replaces.


14. Final Conclusions

uv is the first Python tool in fifteen years that makes Python’s package management feel like what Cargo, npm, or Bundler made their ecosystems feel like a decade ago. That sounds like marketing copy. It isn’t. It’s what happens when someone takes the lessons of every Python tool that came before, the lessons of every adjacent ecosystem, and the engineering capacity of Rust, and points them all at the same problem.

The core thing to take away from this document. uv consolidates the entire Python project lifecycle into one tool, with three architectural choices that compound: (1) statically-compiled Rust binary with no Python bootstrap, (2) global deduplicating cache with CoW/hardlinks, (3) universal lockfile via a forking PubGrub resolver. Once you internalize those, every uv behavior makes sense, and you can predict how it’ll respond to situations you haven’t seen.

When to adopt uv. New projects: yes, almost without reservation. Existing projects with complex tooling debt: yes, but plan the migration. Teams onboarding beginner Python developers: yes — the workflow is genuinely simpler than pip + venv + pip-tools, even if the CLI looks busier on paper. Highly regulated environments with strict supply-chain controls on Python itself: probably yes, but use system Python instead of managed Python, and pin uv’s version aggressively.

When to be cautious. Single-developer projects that are stable and won’t be touched for years: don’t bother migrating, your requirements.txt is fine. Libraries with complex tool.uv.sources overrides: remember those don’t ship to users. Any project where “the team must be able to debug into the package manager” is a hard requirement: that’s a Poetry-level constraint, and it’s a legitimate one.

The bigger picture. Python’s packaging story has been a slow, decade-long convergence on standards: pyproject.toml over setup.py, PEP 517 over implicit setuptools invocation, lockfiles over pip freeze, dependency groups over makeshift extras. uv’s real contribution isn’t speed — speed is the demo. It’s that uv is the first tool that fully implements the standards-based vision and makes it ergonomic. Everything uv does, in principle, you could cobble together from pip-tools + pyenv + pipx + virtualenv + a build backend. uv is the tool that does it without you having to.

Practical advice for someone starting today. Install uv. Do uv init on a small project. Use uv add for everything. Use uv run to execute things. Commit pyproject.toml and uv.lock. Set up CI with uv sync --locked and uv lock --check. Don’t reach for uv pip unless you’re explicitly migrating, don’t reach for workspaces unless you have a real monorepo, don’t pin Python versions in pyproject.toml with upper bounds. Read the gotchas in Section 7 once a month for the first few months — most “weird uv problems” are one of those ten.

The honest closing thought. If uv continues at its current trajectory, in two or three years it will be the default way Python projects are managed, and pip/Poetry/pyenv will be in the position that easy_install is in today: still works, still maintained, but used mainly for legacy reasons and by people who haven’t migrated. That’s a strong claim, and it might be wrong — corporate funding can change, ecosystems can split, better tools can emerge. But it’s the trajectory the evidence currently supports. Adopting uv now is a bet on that trajectory. It’s a low-risk bet, because the work is reversible and the standards underneath are open. And the payoff — for daily developer experience, for CI speed, for new-contributor onboarding, for “it works on my machine” reduction — starts from day one.

That’s uv. It’s not magic. It’s just unusually good engineering applied to a problem that had been ignored for too long.


The ideas are mine. The writing is AI assisted