I started using uv because the benchmarks seemed too good to be true. 10–100x faster than pip. Resolves and installs in milliseconds. Replaces pip, virtualenv, pyenv, and poetry in one binary.
After digging into the source code, the contributing guide, and the official resolver internals docs, I understand why it's fast—and the answers are more interesting than just "it's written in Rust." This post is for people who want to understand how uv actually works and might eventually want to contribute to it.
We'll go step by step: from the repository structure, to what happens when you type uv init, to how the dependency resolver makes decisions. No prior Rust knowledge needed, though some programming experience helps.
1. What uv is (the short version)
uv is an extremely fast Python package and project manager written in Rust, built by Astral. It replaces most of the tools you already use:
| Tool you know | What uv replaces it with |
|---|---|
pip install | uv pip install |
pip-compile | uv pip compile |
virtualenv / venv | uv venv |
pyenv | uv python install |
pipx run | uvx / uv tool run |
poetry / rye | uv init + uv add |
It has 82k+ GitHub stars, is used in production at scale, and its dependency resolver is the designated replacement for Cargo's (Rust's own package manager) solver. That last fact is what originally made me pay attention.
2. The repository layout
Before we trace what uv init does, it helps to understand where the code lives. Clone the repo and you'll see:
uv/
├── crates/ # All the Rust source code, split into many crates
├── docs/ # MkDocs documentation
├── python/ # A small Python package (the uv wheel)
├── scripts/ # Benchmarking, release tooling
├── test/ # Test fixtures and requirement files
├── Cargo.toml # Rust workspace root
└── pyproject.toml # uv manages itself with uv
The real action is in crates/. Rust does not allow circular dependencies between crates (packages), so the uv team split the code into a hierarchy of focused crates. Each crate does one thing:
crates/
├── uv/ # The CLI binary — entry point, argument parsing
├── uv-resolver/ # Dependency resolution (uses PubGrub)
├── uv-installer/ # Downloading and installing packages
├── uv-client/ # HTTP client for PyPI, async network layer
├── uv-workspace/ # Reading pyproject.toml, workspace discovery
├── uv-python/ # Python version management
├── uv-cache/ # Global content-addressed cache
├── uv-distribution/ # Wheel and sdist handling
├── uv-build/ # Build backend (uv's own build system)
├── uv-types/ # Shared type definitions
├── uv-pep440/ # Python version specifier parsing (PEP 440)
├── uv-pep508/ # Dependency specifier parsing (PEP 508)
└── ... (many more)
This structure is intentional. You can visualize the full dependency graph between crates with:
cargo depgraph --dedup-transitive-deps --workspace-only | dot -Tpng > graph.png
The key insight: uv (the binary) sits at the top and depends on everything. uv-types sits at the bottom and depends on almost nothing. Code flows downward—no cycles.
3. What happens when you run uv init
Let's trace the simplest possible command: uv init my-project. Understanding what this does reveals how the whole system is wired together.
Step 1: Argument parsing (in uv crate)
The uv crate is the binary entry point. It uses clap for argument parsing. When you run uv init my-project, clap matches the init subcommand and extracts the project name and any flags (--lib, --app, --package, --bare, etc.).
The parsed arguments are handed to the init command handler.
Step 2: Workspace discovery
Before creating anything, uv checks whether you're already inside an existing workspace. It walks up the directory tree looking for a pyproject.toml with a [tool.uv.workspace] section. This is handled by uv-workspace.
If you're inside a workspace, uv can add your new project as a workspace member automatically. If not, it proceeds with a standalone project.
Step 3: Scaffold the project files
For a basic uv init my-project, uv creates this on disk:
my-project/
├── .python-version # pins the Python version (e.g., "3.12")
├── README.md
├── main.py # boilerplate main function
└── pyproject.toml # project metadata
The generated pyproject.toml looks like:
[project]
name = "my-project"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
There is no [build-system] section by default, which means this project is not packaged—it won't be installed into the virtual environment as a package. This is the right default for applications like web servers or scripts.
If you pass --lib or --package, uv also generates a src/ layout and adds a [build-system] section that points to uv_build—uv's own build backend, which is also written in Rust.
Step 4: Git initialization
uv runs git init in the new directory and writes a .gitignore that excludes .venv/ and __pycache__/. You can skip this with --no-vcs.
No virtual environment is created yet. uv is lazy about that—it only creates the environment when you actually need to run something or install a package.
4. What happens when you run uv add requests
This is where things get interesting. uv add is the command that installs a package and records it in your pyproject.toml. Let's trace it.
$ uv add requests
Creating virtual environment at: .venv
Resolved 6 packages in 400ms
Installed 6 packages in 18ms
+ certifi==2025.1.31
+ charset-normalizer==3.4.1
+ idna==3.10
+ requests==2.32.3
+ urllib3==2.4.0
Stage 1: Read the project state
uv-workspace reads pyproject.toml and builds an in-memory model of your project: its name, version, existing dependencies, Python version requirement, and any workspace configuration.
Stage 2: Update pyproject.toml
Before even touching the network, uv adds requests to the dependencies array in your pyproject.toml:
dependencies = [
"requests>=2.32.3",
]
uv picks the version specifier (>=2.32.3) based on the resolved version (more on this in a moment). This edit happens through uv-workspace's pyproject_mut module, which surgically edits the TOML without reformatting anything you didn't touch.
Stage 3: Resolution — the core loop
This is where the dependency resolver runs. The resolver lives in uv-resolver. At a high level, it answers: "Which exact version of every package do I need to install so that all constraints are satisfied?"
We'll cover this in depth in section 5. For now, the output is a complete set of (package, version) pairs: requests==2.32.3, urllib3==2.4.0, etc.
Stage 4: Lock the result
The resolved set is written to uv.lock. This is a TOML file that records every package, its exact version, its source URL, its hash, and which other packages it depends on. It looks like:
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
[[package.metadata.requires-dist]]
name = "certifi"
specifier = ">=2017.4.17"
# ... and so on
The lockfile is universal: it records all possible versions for all platforms. A single uv.lock works on macOS, Linux, and Windows, even if those platforms need different binary wheels. This is a significant advantage over pip freeze or requirements.txt, which are platform-specific.
Stage 5: Download packages (via uv-client)
uv-client is uv's async HTTP client, built on reqwest and Tokio. For every package that isn't already in the global cache, uv fires off concurrent downloads:
# pip does this sequentially:
fetch requests → wait → fetch urllib3 → wait → fetch certifi → wait → ...
# uv does this concurrently:
fetch requests ─┐
fetch urllib3 ├── all at the same time
fetch certifi ─┘
This is the single biggest source of uv's speed advantage on cold cache installs. Network round trips are expensive. Doing 50 of them serially vs. in parallel is a night-and-day difference.
Packages are stored in a global content-addressed cache managed by uv-cache. The cache key is the package's hash (SHA-256). This means:
- If you have 20 projects that all use
requests==2.32.3, it's downloaded exactly once, ever. - The cache is verified by hash, so corruption is caught immediately.
- The cache is shared across all uv commands, including
uvx.
Stage 6: Install packages (via uv-installer)
"Install" sounds like copying files. It mostly isn't. uv-installer uses hard links where the filesystem supports it (most Linux and macOS filesystems do):
A hard link means two directory entries point to the same physical data on disk. No bytes are copied. Creating a hard link is nearly instantaneous.
So when uv "installs" requests into .venv/lib/python3.12/site-packages/, it's mostly just creating hard links from the global cache into the virtual environment. This is why you see timings like Installed 6 packages in 18ms—18ms is the overhead of creating ~200 directory entries, not copying megabytes of files.
On filesystems that don't support hard links across devices (e.g., when your home directory and temp are on different mounts), uv falls back to copy-on-write (reflinks) or regular copies. But the common case is hard links.
5. Inside the resolver: how uv picks versions
The dependency resolver is arguably the most complex part of uv. Let me explain it from scratch.
The problem
Imagine your project requires:
requests >= 2.28flask >= 3.0
flask requires werkzeug >= 3.0. requests requires urllib3 >= 1.21. urllib3 has versions 1.x and 2.x. Some packages only work with urllib3 1.x. Some only work with urllib3 2.x.
The resolver's job is to find a specific version for every package—including transitive dependencies—such that no two packages have conflicting requirements. This has to work for potentially hundreds of packages with thousands of version combinations.
In the general case, this is equivalent to the Boolean Satisfiability Problem (SAT), which is NP-complete. But in practice it's fast, because package dependency graphs have structure that pure SAT problems don't.
What uv uses: PubGrub
uv's resolver is built on pubgrub-rs, the Rust implementation of the PubGrub algorithm, invented by Natalie Weizenbaum for the Dart package manager in 2018.
PubGrub is a conflict-driven clause learning (CDCL) solver. Here's the intuition:
Old approach (backtracking): Pick a version. Try it. If a conflict is found, undo and try a different version. In the worst case, this tries exponentially many combinations.
PubGrub's approach: When a conflict is found, learn from it. Record exactly why it failed as an incompatibility clause. Then, whenever the resolver would reach a state that triggers that same conflict, skip it entirely—without exploring it again.
For example: if the resolver discovers that a==2.0 and b==3.0 can't coexist (because they require different versions of c), it records the incompatibility {a==2.0, b==3.0}. Now any future state that includes both of those can be pruned immediately, without re-exploring the subtree.
The resolver loop (step by step)
Here is how PubGrub actually runs inside uv, from the official internals documentation:
1. Start with a virtual root package. The "root" is your project itself. Only the root is decided initially; everything else is undecided.
2. Pick the highest-priority undecided package. uv's priority order is:
- URL dependencies (git, path, file) — they have no versions to negotiate
- Packages pinned with
==— the version is known immediately - Highly conflicting packages (detected heuristically, see below)
- Everything else, in order of when they were first encountered
This breadth-first approach ensures direct dependencies are decided before transitive ones.
3. Pick a version for that package. uv tries versions from newest to oldest (or oldest to newest if you set resolution = "lowest"). It prefers versions from your existing uv.lock (so re-resolves are stable) and versions already installed in the environment.
4. Add that version's requirements to the undecided set. uv prefetches metadata for newly discovered packages in the background while the resolver continues—this is the async advantage again.
5. Repeat, or backtrack on conflict. If no conflict: pick the next package. If conflict: PubGrub identifies which two already-decided packages caused it, adds the incompatibility, backtracks to before one of them was decided, and tries again with the new constraint.
6. Done. Either all packages have a decided version (success), or an incompatibility propagates back to the root (failure), meaning there is no valid solution.
The conflict-heuristic
uv adds one heuristic on top of PubGrub's base algorithm. Imagine package A is decided first (high priority), and every time the resolver tries a version of package B, it's immediately rejected because of a conflict with A. The resolver has to try every version of B before concluding that A's chosen version is the root cause.
After uv sees this pattern five times, it marks A and B as "highly conflicting" and bumps B's priority above A's. Then it manually backtracks to before A was decided and tries B first. This avoids exhausting all versions of B unnecessarily.
Forking: one resolver for all platforms
This is a feature that no other Python resolver has implemented until recently. Consider this requirement:
numpy>=2,<3 ; python_version >= "3.11"
numpy>=1.16,<2 ; python_version < "3.11"
A naive resolver would fail here—Python only allows one version of a package installed at a time. But uv uses a forking resolver: whenever it sees multiple requirements for the same package with different environment markers, it splits the resolution into separate forks.
In this example, one fork resolves for python_version >= "3.11" (picks numpy 2.x) and another fork resolves for python_version < "3.11" (picks numpy 1.x). Both results end up in uv.lock, tagged with their markers:
[[package]]
name = "numpy"
version = "2.3.0"
# marker: python_version >= "3.11"
[[package]]
name = "numpy"
version = "1.26.4"
# marker: python_version < "3.11"
When you actually install the lockfile on a machine, uv looks at the real Python version and installs only the appropriate version. The lockfile is universal; the installation is platform-specific.
Forks can be nested (a fork inside a fork), and forks with identical packages are merged to keep things manageable.
PubGrub's error messages
One of PubGrub's most celebrated features is what happens when resolution fails. Instead of "conflicting requirements detected", PubGrub can reconstruct exactly why no solution exists:
× No solution found when resolving dependencies:
╰─▶ Because my-project depends on flask>=3.0 and flask>=3.0 requires
werkzeug>=3.0, my-project requires werkzeug>=3.0.
And because legacy-lib==1.0 requires werkzeug<2.0, and my-project
depends on legacy-lib==1.0, my-project's requirements are
unsatisfiable.
It walks through the incompatibility chain, package by package, so you know exactly what to fix. pip's equivalent error usually just tells you what conflicted, not why or how to resolve it.
6. The cache in detail
The global cache deserves more attention than it usually gets. It lives at:
~/.cache/uvon Linux~/Library/Caches/uvon macOS%LOCALAPPDATA%\uv\cacheon Windows
Inside, it's organized roughly like:
~/.cache/uv/
├── wheels/ # Built and downloaded wheel files, keyed by hash
├── sdists/ # Source distributions
├── builds/ # Built wheels from sdists
├── interpreter/ # Python interpreter metadata cache
└── simple/ # PyPI "simple" index responses
The wheel cache stores packages by their content hash. When uv installs a package into a virtual environment, it checks the cache first. If the wheel is cached, it creates hard links from the cache into .venv/—no download, no extraction, essentially no time. If the wheel isn't cached, it downloads it, stores it in the cache, then hard-links it.
The simple/ cache stores HTTP responses from PyPI's simple index (the list of available versions for each package). This is why re-resolving with an existing uv.lock is so fast—uv can often skip network requests entirely, relying on cached index responses and the lockfile's version preferences.
You can inspect cache usage with:
uv cache dir # where it is
uv cache prune # remove unused entries
7. Why Rust?
The Rust choice is not aesthetic—it unlocks specific capabilities that matter for uv's design.
No garbage collector pauses
Python, Java, and Go all have garbage collectors. A GC occasionally pauses program execution to clean up unreachable memory. For a CLI tool that runs for milliseconds, a 20ms GC pause is catastrophic. Rust has no GC—memory is freed deterministically at the end of each variable's scope, which the compiler enforces at compile time through its ownership system.
Ownership makes concurrency safe
uv fires hundreds of network requests concurrently. In most languages, concurrent access to shared data is a footgun—you get race conditions, deadlocks, or corrupted state. Rust's ownership and borrowing rules make data races impossible to compile. If your code compiles, it doesn't have race conditions. This means the uv team can write aggressively concurrent code without worrying about a whole class of bugs.
Zero-cost abstractions
When uv uses Tokio's async/await syntax, the compiler transforms it into a state machine that runs efficiently on a thread pool—no extra overhead per .await point. When uv uses iterators and closures, the compiler often inlines them entirely. These "zero-cost abstractions" mean uv's code reads like high-level Python but executes with the efficiency of hand-written C.
Direct OS primitives
Rust gives uv direct access to system calls like linkat() (hard links) and sendfile() (zero-copy file transfer). Python can call these via os or ctypes, but Rust does it without the interpreter overhead, and the lifetimes and error handling are enforced by the type system.
8. The uv.lock format
The lockfile is not a stable public API (the format can change between uv versions), but understanding it helps you understand what the resolver produces.
version = 1
requires-python = ">=3.12"
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
[package.metadata]
requires-dist = [
{ name = "certifi", specifier = ">=2017.4.17" },
{ name = "charset-normalizer", specifier = ">=2,<4" },
{ name = "idna", specifier = ">=2.5,<4" },
{ name = "urllib3", specifier = ">=1.21.1,<3" },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
[[package.metadata.requires-dist]]
name = "certifi"
specifier = ">=2017.4.17"
# sdist and wheel hashes
[[package]]
name = "urllib3"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:...", size = 200288 }
wheels = [
{ url = "https://files.pythonhosted.org/...", hash = "sha256:...", filename = "urllib3-2.4.0-py3-none-any.whl" },
]
The hashes in the lockfile serve two purposes: integrity verification (the downloaded file matches what was resolved) and cache keying (the hash is the cache key).
If you want to read the lockfile programmatically, use uv workspace metadata instead—it produces a stable JSON representation of the same information.
9. How to run uv from source
If you want to contribute to uv or just poke around, here's the minimal setup:
# Install Rust (if you don't have it)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Clone
git clone https://github.com/astral-sh/uv.git
cd uv
# Build and run (this compiles the whole thing, takes a few minutes first time)
cargo run -- --version
cargo run -- pip install requests
cargo run -- venv
# Run the test suite
cargo nextest run # you'll need: cargo install cargo-nextest
For profiling and understanding concurrency, you can enable trace-level logging:
RUST_LOG=uv=info cargo run -- pip compile requirements.in
RUST_LOG=trace cargo run -- pip install requests # very verbose
The RUST_LOG env var controls which crates emit logs and at what level. Setting uv=info gives you high-level resolver decisions. Setting trace gives you everything.
Finding a first contribution
The uv team labels beginner-friendly issues as good first issue. These typically don't require deep Rust or resolver knowledge—things like improving error messages, adding missing CLI flags, or fixing edge cases in TOML parsing.
The team is active on GitHub and responsive to questions. If you want to take on something larger, comment on the issue first to make sure no one else is working on it and to get early feedback on your approach.
Putting it all together
When you type uv add requests, here's everything that happens, now that you know the internals:
uvcrate parses the arguments via clap.uv-workspacereadspyproject.tomland discovers the workspace structure.uv-workspace(pyproject_mut) writesrequests>=X.Y.Zintodependenciesinpyproject.toml.uv-resolverstarts the PubGrub loop. It queries PyPI viauv-clientfor package metadata, concurrently for all discovered packages.- PubGrub iterates: pick highest-priority package → pick a version → add its requirements → detect conflicts → learn incompatibilities → repeat.
- The forking resolver splits the search space if environment markers diverge (e.g., platform-specific dependencies).
uv-resolverwrites the result touv.lock.uv-installerchecksuv-cachefor each package. Cached packages are hard-linked into.venv/. Missing packages are downloaded concurrently byuv-client, stored in the cache, then hard-linked.- Done. The whole thing—resolve + download + install—typically finishes in under a second for warm caches.
The speed is not magic. It is the accumulation of many correct engineering choices: concurrent I/O, a smarter algorithm, a content-addressed cache, and a language that makes all of this safe to write.
Further reading
If this post made you curious, here are the best next steps:
- uv resolver internals docs — the official deep dive into the resolver, written by the uv team
- PubGrub blog post — Natalie Weizenbaum's original explanation of the algorithm, very approachable
- pubgrub-rs internals guide — the Rust implementation's own documentation of the algorithm
- uv CONTRIBUTING.md — setup instructions, testing, profiling guide
- uv GitHub issues (good first issue) — starting points for contributors
- Tokio async runtime — the async runtime powering uv's concurrent network layer
- The Rust Book — if uv's internals made you want to learn Rust