Back to glossary

uv (Python package manager)

uv is Astral's Rust-based Python package and project manager that resolves dependencies into a hashed, cross-platform uv.lock and installs from it.

Also known as uv python, Astral uv, uvx, uv package manager, uv.lock

uv is a Python package and project manager from Astral, written in Rust, that replaces pip, pip-tools, virtualenv, pipx, and pyenv. A uv Python project resolves once into a cross-platform, hash-pinned uv.lock file and installs from it. That makes installs fast and reproducible, but locking, source builds, and uvx can still run untrusted code.

We tested uv 0.13.1, the current release as of October 15, 2026. Its installs are tightly controlled: one altered hash in uv.lock stopped uv sync cold, and its default index strategy blocks the classic dependency confusion attack. The exposure sits elsewhere. uv lock executed a dependency's setup.py before anything was installed. A hand-injected lockfile entry passed uv lock --check and installed. A cooldown passed on the command line vanished on the next uv sync, and uvx resolves tools against the live index with no lockfile at all.

Our position: uv's defaults are safer than pip's and Poetry's, and the commands that resolve dependencies, not the ones that install them, are where a uv project's supply chain is open. Treat uv add, uv lock, and uvx like running a stranger's code, because sometimes they do.

What is uv in Python?

#

uv is a single static binary that manages a Python project end to end: it installs Python itself, creates the virtual environment, resolves dependencies into a lockfile, installs them, runs commands, builds distributions, and publishes them. Astral's documentation describes it as "a single tool to replace pip, pip-tools, pipx, poetry, pyenv, twine, virtualenv, and more" (uv docs).

uv first appeared on PyPI on February 15, 2024 (release history). It began as a faster pip and grew into a project manager in August 2024, when Astral added lockfiles, workspaces, tool management, and Python installation (our coverage). It is still versioned 0.x: 0.13.1 shipped on October 14, 2026.

Adoption is now mainstream. PyPI recorded 127,754,131 downloads of the uv package in the 30 days to October 15, 2026, against 624,210,278 for pip (pypistats). That understates uv, because its curl installer and GitHub release binaries bypass PyPI. On March 19, 2026, Astral announced an agreement to join OpenAI's Codex team and said "OpenAI will continue supporting our open source tools after the deal closes" (Astral).

The everyday workflow is four commands. This is a real session:

Bash
$ uv --version
uv 0.13.1 (x86_64-unknown-linux-gnu)
$ uv init proj && cd proj
Initialized project `proj` at `/work/proj`
$ uv add requests
Using CPython 3.13.16 interpreter at: /usr/bin/python3.13
Creating virtual environment at: .venv
Resolved 6 packages in 4ms
Installed 6 packages in 3ms
 + certifi==2026.7.22
 + charset-normalizer==3.5.2
 + idna==3.20
 + proj==0.1.0 (from file:///work/proj)
 + requests==2.34.2
 + urllib3==2.8.0
$ uv tree
proj v0.1.0
└── requests v2.34.2
    ├── certifi v2026.7.22
    ├── charset-normalizer v3.5.2
    ├── idna v3.20
    └── urllib3 v2.8.0

uv add edited pyproject.toml, resolved the tree, wrote uv.lock, created .venv, and installed. uv run <cmd> locks, syncs, and runs a command inside that environment, so nobody has to activate a virtual environment by hand. uv also keeps a pip-compatible interface, uv pip install, which works like pip and does not use uv.lock.

Why is uv faster than pip and Poetry?

#

uv is faster than pip and Poetry mainly because of the work it avoids, not because it is written in Rust. Andrew Nesbitt's analysis concludes that "uv is fast because of what it doesn't do, not because of what language it's written in" (How uv got so fast). Astral's own claim is 10 to 100 times faster than pip.

The biggest saving is that uv rarely downloads a package to learn its dependencies. PyPI serves each file's core metadata separately (PEP 658), so the resolver reads a few hundred bytes instead of a full wheel. This is the record PyPI's Simple API returns for the requests wheel above:

Bash
$ curl -s -H "Accept: application/vnd.pypi.simple.v1+json" \
    https://pypi.org/simple/requests/ | jq '.files[] | select(.filename=="requests-2.34.2-py3-none-any.whl")'
{
  "core-metadata": {
    "sha256": "8c384ba3e979480faae2859d3c5e6c1276dd2c3616e322e124d52c8cfc556f27"
  },
  "filename": "requests-2.34.2-py3-none-any.whl",
  "hashes": {
    "sha256": "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"
  },
  "provenance": "https://pypi.org/integrity/requests/2.34.2/requests-2.34.2-py3-none-any.whl/provenance",
  "requires-python": ">=3.10",
  "size": 73075,
  "upload-time": "2026-05-14T19:25:26.443000Z",
  "url": "https://files.pythonhosted.org/packages/a0/f4/.../requests-2.34.2-py3-none-any.whl",
  "yanked": false
}

When no metadata file exists, uv reads the zip central directory at the end of a wheel with HTTP range requests, and only then downloads the whole file. The remaining savings are architectural. Downloads run in parallel. A global cache holds one copy of each unpacked package and hardlinks or clones it into every environment: requests/sessions.py in our project had a link count of 3, the cache plus two virtual environments. uv skips bytecode compilation by default, and its PubGrub resolver learns from each conflict instead of retrying similar dead ends.

Every field in that record matters for security too. hashes is what uv.lock pins. upload-time is what dependency cooldowns filter on. provenance points to a PEP 740 attestation that, as of 0.13.1, uv uploads when publishing but exposes no option to verify when installing.

How does a uv Python project lock its dependencies?

#

A uv Python project locks its dependencies with one universal resolution: uv solves for every platform and Python version the project supports and writes the result to uv.lock. One file serves a macOS laptop and a Linux CI runner alike (uv docs). This is the entry for requests:

Plain text
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "certifi" },
    { name = "charset-normalizer" },
    { name = "idna" },
    { name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/.../requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/a0/f4/.../requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]

The lockfile pins files, not just versions. Each entry carries the exact download URL, SHA-256 hash, size, and upload time of every sdist and wheel. Our six-package lock held 116 hashes, most of them platform wheels for charset-normalizer.

uv enforces those hashes. We replaced the requests wheel hash with zeros and synced with an empty cache:

Plain text
$ uv sync --frozen
error: Failed to download `requests==2.34.2`
  cause: Hash mismatch for `requests==2.34.2`

         Expected:
           sha256:0000000000000000000000000000000000000000000000000000000000000000

         Computed:
           sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0

The lockfile protects only the commands that read it without rewriting it. uv run, uv add, and uv tree lock before they act, so editing pyproject.toml and running uv run silently re-resolves against the live index. uv sync --locked fails instead if uv.lock is out of date with pyproject.toml, and uv sync --frozen skips that check entirely (uv docs). CI should use --locked.

The hashes prove that a file matches the lock, not that the lock is honest. A malicious pull request or a compromised bot account can edit uv.lock directly. We added nine lines to uv.lock, a new package served from a direct URL with its correct hash plus a dependency edge from requests to it, and left pyproject.toml alone:

Plain text
$ uv lock --check
Resolved 7 packages in 0.89ms
$ uv sync --locked
 + six==1.17.0 (from https://files.pythonhosted.org/packages/b7/ce/.../six-1.17.0-py2.py3-none-any.whl)

Both checks passed, and a plain uv lock kept the injected entry. The real requests 2.34.2 has no such dependency. Reviewers are unlikely to notice, because GitHub's Linguist marks uv.lock as generated (generated.rb), which collapses it in pull request diffs. uv lock --check --refresh caught it, because --refresh discards cached metadata and re-resolves against the index:

Plain text
$ uv lock --check --refresh
Resolved 6 packages in 129ms
error: The lockfile at `uv.lock` needs to be updated, but `--check` was provided.

Run that in CI on every pull request that touches uv.lock, and add uv.lock -linguist-generated to .gitattributes so the diff is shown. The check needs network access and runs the same resolution code as uv lock, so run it without credentials.

A lockfile also cannot help once the file it names turns bad. Astral notes that lockfile URLs point straight at object storage, so a release placed under PyPI's quarantine status can still install from an existing lock (Astral). As we argued after the axios compromise, lockfiles make existing installs deterministic; they do not make new resolution safe (analysis).

Where package code runs in a uv workflow

#

Package code can run at four points in a uv workflow, and the first comes before anything is installed. A wheel is a prebuilt archive, and installing one copies files without running package code. A source distribution (sdist) has to be built, which means running its build backend, and for a setuptools project that means executing setup.py.

StageCommandsWhen package code runsEffect of no-build
1. Lockuv add, uv lockAn sdist with no static metadata is asked for its dependencies through its build backendRefuses the build; resolution fails for that package
2. Syncuv sync, uv runWheels run no code. A locked sdist is built when no locked wheel fits the machineRefuses the build
3. Startupuv run, python, uv's interpreter probeA .pth file in site-packages runs at every interpreter start; importing a package runs its module codeNone
4. Toolsuvx, uv tool runResolves and installs a tool and its dependencies fresh, outside any project lockfileApplies when passed as a flag or set in config

Stage 1 surprises people. We added docopt, which publishes only an sdist, and ran uv lock with an empty cache. uv called the package's setuptools build hooks, which execute its setup.py, before installing anything:

Plain text
$ uv lock --refresh -v
DEBUG Preparing metadata for: docopt==0.6.2
DEBUG Calling `setuptools.build_meta:__legacy__.get_requires_for_build_wheel()`
DEBUG Calling `setuptools.build_meta:__legacy__.prepare_metadata_for_build_wheel()`

Unlike Poetry, uv has a lock-time switch. With --no-build, or no-build = true under [tool.uv], uv refused:

Plain text
$ uv lock --refresh --no-build
hint: Wheels are required for `docopt` because building from source is disabled for all packages (i.e., with `--no-build`)

Build backends are a live delivery path. In the MemTensor compromise on September 23, 2026, the malicious MemoryOS 2.0.34 sdist replaced its standard Poetry build backend with a bundled sckit_poetry_build.py, so building that sdist would have run the attacker's file. We think sdist builds are Python's install scripts and deserve the same treatment npm finally gave lifecycle scripts: npm v12 made them opt-in in July 2026, after pnpm 10 did. uv still builds sdists by default. The cost of refusing is real: packages with no wheel for your platform, and private packages that ship only sdists, fail until you list them in no-build-package exceptions.

no-build does nothing about stage 3. Python's site module executes any .pth line that starts with import at every interpreter start. We built a wheel containing one such line, installed it with uv add --no-build, and nothing ran. The next uv run python -c 'print("hello")' ran it. So did a plain uv sync with an empty cache, because uv starts the environment's interpreter to inspect it. The Hades wave of the Miasma campaign used exactly this path, shipping 37 malicious PyPI wheels with a *-setup.pth loader (our analysis).

Does uv prevent dependency confusion?

#

uv prevents the classic form of dependency confusion by default. Its first-index strategy takes each package only from the first configured index that has it, so a higher version of your internal package name on PyPI is never considered (uv docs). pip does the opposite: it merges candidates from --index-url and every --extra-index-url and takes the best version, the behavior uv offers as unsafe-best-match and documents as exposing users to dependency confusion.

In our Poetry tests, an attacker's acme-billing 99.0.0 on a public index beat the internal 1.2.0 until every internal name was pinned; uv locked the internal versions with no per-package pins. The tighter configuration marks the internal index explicit and pins each internal package to it:

Plain text
[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.example.com/simple"
explicit = true

[tool.uv.sources]
acme-billing = { index = "internal" }
acme-core = { index = "internal" }

first-index has limits. It trusts index order, and indexes passed on the command line or through UV_INDEX take precedence over those in pyproject.toml, so a CI variable can reorder them. It cannot help when the internal index itself proxies PyPI, which moves the confusion into the mirror. Setting UV_INDEX_STRATEGY=unsafe-best-match to get past a resolution error switches the protection off. The explicit index and per-package sources above hold regardless of order.

Dependency cooldowns with exclude-newer

#

A dependency cooldown makes the resolver ignore any file uploaded within a recent window, so a malicious release that is caught and removed quickly never enters your lock. uv added relative durations to exclude-newer in 0.9.17 on December 9, 2025 (changelog). It works because every file in PyPI's Simple API carries an upload-time, standardized in PEP 700, and uv compares each file's upload time individually.

We relocked the demo project with a 30-day dependency cooldown on October 15, 2026:

Plain text
$ uv lock --exclude-newer "30 days"
Resolving despite existing lockfile due to addition of global exclude newer 2026-09-15T22:09:25Z
Resolved 6 packages in 973ms
Updated charset-normalizer v3.5.2 -> v3.5.1
Updated idna v3.20 -> v3.19

The cutoff landed on September 15, 2026. idna 3.20 (uploaded September 17) and charset-normalizer 3.5.2 (September 30) fell inside the window, so uv chose idna 3.19 (August 18) and charset-normalizer 3.5.1 (August 15). requests 2.34.2, certifi 2026.7.22, and urllib3 2.8.0 were older than the cutoff and did not change.

The window does not need to be long. In the year to August 2024, PyPI resolved over 90% of malware reports in under 24 hours (our coverage). In the PyTorch Lightning compromise on April 30, 2026, Socket's AI scanner flagged the malicious versions 18 minutes after publication (our analysis).

Where you set the cooldown matters. uv records it in the lockfile's [options] table as exclude-newer-span = "P30D". When we then ran a plain uv sync without the flag, uv printed "Resolving despite existing lockfile due to removal of global exclude newer" and deleted the setting from uv.lock. It kept the already-locked versions, so nothing changed yet, but the cooldown was gone: the next uv lock --upgrade would take the newest releases. Put it in pyproject.toml, where every developer and CI job inherits it:

Plain text
[tool.uv]
exclude-newer = "7 days"
# Let one package through for an urgent fix, then remove the override:
# exclude-newer-package = { cryptography = false }

uv's cooldown fails closed: files from an index that publishes no upload time are treated as unavailable unless you opt that index out (uv docs). pip 26.2.1's --uploaded-prior-to is "only effective when using indexes that provide upload-time metadata," per its help text. The cost of any cooldown is that security fixes wait too. And unlike pnpm 11, whose minimum release age defaults to 1,440 minutes (pnpm docs), uv applies no cooldown unless you configure one.

uvx resolves outside the lockfile

#

uvx is an alias for uv tool run, the Python equivalent of npx: it installs a command-line tool into a temporary environment and runs it. On first use it resolves the latest version of the tool and every dependency against the live index; later runs reuse the cached environment until the cache is pruned or you ask for tool@latest (uv docs). None of it appears in your project's uv.lock.

That is the same gap the axios compromise exposed for npx. A pinned uvx black@26.10.1 fixes black's version, but black declares ranges such as click>=8.0.0, so its dependencies resolve to whatever the index served at the moment the cache was filled. The same applies to MCP server configurations and CI jobs that launch tools with uvx.

Two settings narrow it. A user-level ~/.config/uv/uv.toml with exclude-newer = "7 days" applies to uvx as well as projects. For tools you run repeatedly, uv tool install gives a persistent environment that changes only when you run uv tool upgrade.

What uv's built-in checks catch

#

uv now ships two checks of its own, both previews as of 0.13.1. uv audit queries OSV for known vulnerabilities in the locked versions. The malware check, enabled with UV_MALWARE_CHECK=1, looks up locked packages against OSV's MAL advisories before syncing. Astral's William Woodruff says that when one matches, "the sync will be terminated before the malicious code has a chance to run" (Astral).

Both depend on someone else having already reported the package. uv audit reports known vulnerabilities, and malware does not get a CVE. A malware advisory exists only after detection, and OSV's feed can also be wrong: on May 26, 2026, OSV withdrew 157 malware reports after automated false positives flagged packages including FastAPI. A check that blocks syncs on that feed inherits both its delay and its errors.

uv's own parser has been a target. CVE-2025-54368, fixed in uv 0.8.6 on August 7, 2025, let an attacker "contrive a ZIP that appears harmless to security scanners" while uv extracted different contents, with the archive's hash unchanged (Astral, GHSA-8qf3-x8v5-2pj8). PyPI found no exploitation and now rejects such archives. The lesson is general: a matching hash proves you got the file the lock names, not that a scanner and an installer saw the same contents. Analysis has to cover what the installer will actually unpack.

Socket works with uv in two places. Socket Firewall Free wraps uv commands, as in sfw uv pip install flask, and runs an ephemeral HTTP proxy that checks each package request against Socket's API, blocking known malware before uv downloads it (docs). It can only block network requests, and uv's global cache serves hits with none, so run uv cache clean once before relying on it. Socket's scans read uv.lock directly, along with pylock.toml, the standard lockfile format uv can export (language support).

uv, pip, and Poetry compared

#
Controluv 0.13.1pip 26.2.1Poetry 2.5.1
Lockfileuv.lock, universal, hashes every filepip lock writes pylock.toml, marked experimentalpoetry.lock, universal, hashes every file
Hash verificationAlways, from uv.lockOnly in hash-checking modeAlways, from poetry.lock
Multiple indexesFirst index wins by defaultBest version across all indexesBest version across primary sources
Refuse builds while locking--no-build works at lock time--only-binary :all:No lock-time switch
Release-age cooldownexclude-newer, fails closed--uploaded-prior-to, fails opensolver.min-release-age, fails open
Known-malware checkOpt-in preview, OSV MALNone built inNone built in
Trusted publishinguv publish, with PEP 740 attestationsNot a pip feature; twine or the PyPA actionNo native OIDC path

Our verdict: use uv for new projects, and especially for any project that mixes internal and public indexes, because its defaults fail closed where pip's and Poetry's fail open. Keep pip for one-off installs in throwaway environments, with --require-hashes when the requirements are pinned. Keep an existing Poetry project only if you apply the hardening in our Poetry glossary entry.

Every control in that table operates on names, versions, hashes, and timestamps. None of them looks at what a package does. A maintainer account takeover that publishes a malicious release and stays undetected past your cooldown passes every one of them, with a valid hash and, if published through a compromised CI pipeline, valid provenance. No installer closes that gap today. Its job ends at proving the bytes match the lock, and the open problem is deciding, before resolution, whether those bytes should run at all.