DEV Community

Cover image for Migrating from pip and venv to uv
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Migrating from pip and venv to uv

I have a Python project on my hands: a tool built on FastAPI and DuckDB that scans file shares and produces compliance reports. When I looked at how its dependencies get installed today, this is what I found: seven separate requirements files, 31 dependency lines in total, and not a single lock file anywhere. requirements.txt, requirements-dev.txt, -accel, -elastic, -mcp, -playground, -tray. Each one was a good idea at the time it was created. Together they are not an installation instruction; they are an archaeological stratum.

This article is the record of migrating that project to uv. Every command output below was actually executed, and the numbers were measured on this Mac. My thesis: uv's real value is not that it's faster than pip — the speed is a pleasant side effect. The real value is that it collapses the distance between the description of your environment and the environment itself. In pip, closing that gap was left to your discipline, and discipline is the first thing to abandon you at three in the morning.

Let's look at the broken part first

This line sits in the project's requirements.txt:

pywin32>=311
Enter fullscreen mode Exit fullscreen mode

No marker. No condition. In other words: "install this on every platform." I tried it on my Mac:

python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode
ERROR: Could not find a version that satisfies the requirement pywin32>=311 (from versions: none)
ERROR: No matching distribution found for pywin32>=311
Enter fullscreen mode Exit fullscreen mode

pywin32 only publishes Windows wheels, so this outcome is expected. The unexpected part is this: that file had been sitting like that for months and nobody noticed, because it worked perfectly for everyone who set the project up on Windows. The error detonates on the first person who changes the environment. If your dependency file is correct inside one platform and wrong outside it, then it is simply wrong — the bill has only been deferred.

This is where uv's difference shows up most clearly, and surprisingly it isn't speed.

The model difference: not an install command, an environment description

pip's mental model is imperative. "Install this." What your environment looks like at any moment is a function of what you installed in what order. A pip freeze output isn't a recipe; it's a photograph taken at the scene.

uv's project interface is declarative. pyproject.toml holds what you asked for, uv.lock holds the resolved form of that request, and .venv holds how that resolution landed on disk. Keeping those three consistent is uv's job, not yours. uv sync is exact by default: it removes packages that aren't in the lock file. uv run, by contrast, is inexact by default and leaves extraneous packages alone — knowing that distinction ends half of your future "but it worked on my machine" arguments before they start.

Diagram

The version I used is uv 0.12.6 (7938ca5d5 2026-08-25). With a tool moving this fast, naming the version isn't politeness, it's a requirement.

The migration: four commands and one surprise

The path the official migration guide suggests is simple: uv init, then pull your existing files in.

uv init --name fa-lab --python 3.13
uv add -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

On the first attempt uv did its job, then stopped:

Resolved 36 packages in 1.16s
error: Distribution `pywin32==312 @ registry+https://pypi.org/simple` can't be installed
because it doesn't have a source distribution or wheel for the current platform

hint: You're on macOS (`macosx_26_0_arm64`), but `pywin32` (v312) only has wheels for the
following platforms: `win32`, `win_amd64`, `win_arm64`
Enter fullscreen mode Exit fullscreen mode

I liked the distinction here: uv resolved all 36 packages, then stopped at the installation stage. pip, given the same file, never got past the resolution stage at all. The same problem being caught in two different places isn't a coincidence, it's the design — uv resolves independently of platform and installs according to the platform you're on.

The surprise came afterwards. After the error I looked at pyproject.toml:

dependencies = []
Enter fullscreen mode Exit fullscreen mode

And no uv.lock had been created at all. So a failed uv add doesn't leave half a job behind; it rolls back. This looks like the bare minimum you'd expect from a package manager, but as someone with years spent cleaning up the wreckage of half-finished installs, I wanted to note it separately.

I added the marker and continued:

uv add "pywin32>=311; sys_platform == 'win32'"
uv add --dev -r requirements-dev.txt
uv add -r requirements-accel.txt --group accel
Enter fullscreen mode Exit fullscreen mode

That is how I pulled in three of the files; 12 + 9 + 2 = 23 lines. The remaining four (-elastic, -mcp, -playground, -tray, 8 lines in total) go in exactly the same way, each as its own --group — I left them out here to avoid repetition. The result:

dependencies = [
    "apscheduler>=3.11.2",
    "click>=8.4.1",
    "cryptography>=41.0",
    "duckdb>=1.5.2",
    "fastapi>=0.136.3",
    "ldap3>=2.9.1",
    "openpyxl>=3.1.0",
    "pyarrow>=23.0.1",
    "pywin32>=311 ; sys_platform == 'win32'",
    "pyyaml>=6.0.3",
    "reportlab>=4.0.0",
    "uvicorn[standard]>=0.27.0",
]

[dependency-groups]
accel = [
    "datasketch>=1.10.0",
    "hyperscan>=0.8.2",
    "imagehash>=4.3.2,<5",
    "pdqhash>=0.2",
    "phonenumbers>=9.0.31",
    "python-magic>=0.4.27 ; sys_platform != 'win32'",
    "python-magic-bin>=0.4.14 ; sys_platform == 'win32'",
    "python-stdnum>=2.2",
    "watchdog>=6.0.0,<7",
]
dev = [
    "httpx>=0.27.0",
    "pytest>=9.0.3,<10",
]
Enter fullscreen mode Exit fullscreen mode

[dependency-groups] is not uv's invention; it's a table standardized by PEP 735, and that PEP's status is Final. The distinction matters, because extras (that is, optional-dependencies) are published metadata and you have to install the package itself to get them. A dependency group is never published. There's no reason for your test dependencies to show up on PyPI.

A small warning: you may still see the old dev-dependencies field under [tool.uv]. The documentation explicitly marks it "legacy" and says it "will be deprecated and removed" eventually. Don't use it in a new project; if an old one has it, uv add --dev will prefer it and you'll inherit the legacy without noticing.

For those who want to preserve versions: you can pass your previously resolved versions as constraints with uv add -r requirements.in -c requirements.txt. If you want a "no version changes at all" guarantee during migration, that's the right path. I deliberately didn't — upgrading was exactly what I wanted in this project.

What's inside the lock file: a platform-independent resolution

The resulting uv.lock is 1,405 lines and contains 57 packages. Searching inside it, I found this:

name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
    { url = "https://files.pythonhosted.org/.../pywin32-312-cp313-cp313-win32.whl",
      hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", ... },
]
Enter fullscreen mode Exit fullscreen mode

A package that isn't installed on my Mac sits in the lock file complete with its hash. This is the thing pip freeze logic cannot do. A requirements.txt produced by pip-tools is a photograph of the platform you're on; you'd need to produce a separate file for Windows and for Linux and stitch them together by hand. uv's project lock carries every platform in a single file and marks which package applies where.

I already argue for lock discipline on the infrastructure side — OpenTofu provider lockfile and supply chain verification is the same logic in a different ecosystem. A dependency chain that doesn't pin hashes is a well-intentioned guess.

If you need to open up to the outside world, both formats exist:

uv export --format requirements.txt -o requirements.txt
uv export --format pylock.toml -o pylock.toml
uv export --format cyclonedx1.5 -o sbom.json
Enter fullscreen mode Exit fullscreen mode

pylock.toml is the lock format standardized by PEP 751 — also Final. So the worry of "I'm locked into uv and can't get out" has no technical basis; the exit door opens onto a standard format. The third format quietly reveals a card worth playing: uv can emit a CycloneDX 1.5 SBOM without installing a separate tool. Try that before you reach for Syft to produce a supply chain report.

Measurement: how fast, and under what conditions

I don't write speed claims without measuring them on my own machine. The setup: Apple Silicon Mac, macOS 26, CPython 3.13.3, with both sides' caches isolated in a scratch directory so nobody gets a home-field advantage. The exact commands measured were these:

# uv column — groups deliberately left out
rm -rf .venv && uv sync --no-default-groups

# pip column — with the pywin32 line removed from the file
rm -rf pipvenv && python3 -m venv pipvenv \
  && ./pipvenv/bin/python -m pip install -r req-nowin.txt
Enter fullscreen mode Exit fullscreen mode

That means 33 third-party packages on both sides. (The 36 above was the resolution count for the whole file including pywin32; this 33 is the number actually installed on macOS — don't conflate the two.) --no-default-groups is essential because uv sync installs the dev group by default while pip install -r does not; without it I'd be handing uv extra work and corrupting my own measurement.

Scenario pip (venv + install) uv sync
Cold cache 29.85 s 17.85 s
Warm cache 6.55 s 0.050 s

In the cold round the gap is about 1.7× (roughly 40% less wall-clock) and largely network-bound. In the warm round it's 6.55 seconds against 50 milliseconds; that's about 130×. What matters is less the number than its qualitative consequence: 50 milliseconds is a duration a human doesn't register as "waiting." Creating an environment stops being an operation and becomes a reflex, and because uv run can quietly sync the environment on every invocation, the "activate the venv first" step disappears entirely.

Honest footnotes: the pip inside the venv was 25.0.1, not the newest release. The measurement was taken on one machine in one run; not laboratory-grade, but enough to show the size of the difference in the field. And let me write this too — uv doesn't get that speed for free.

.venv     221M      uv cache     222M
pipvenv   256M      pip cache      71M
Enter fullscreen mode Exit fullscreen mode

uv's cache is about three times pip's, because it keeps wheels unpacked rather than as compressed archives. Separately, .venv isn't copied out of that cache; it's linked with copy-on-write (link-mode defaults to clone on macOS/Linux). The practical consequence: du counts allocated blocks and counts cloned blocks separately for each file, so it overstates the .venv figure above — those blocks are in fact shared with the cache. Still, if your CI runner's disk fills up, know that you made a deliberate trade — and note the uv cache prune --ci command.

If you don't want a big bang: the uv pip middle road

I don't assume you can do the whole migration above over a weekend. In a repository five teams touch and twelve pipelines feed, "we moved to pyproject" is not a weekend sentence. Fortunately there's an intermediate step: uv's pip interface.

uv venv
uv pip install -r requirements.txt
uv pip compile requirements.in -o requirements.txt
uv pip sync requirements.txt
Enter fullscreen mode Exit fullscreen mode

These are the way to buy just the speed without disturbing your existing workflow. But when you hear the word "drop-in," the question to ask is always the same: where does it deliberately behave differently? The documentation lists these deviations openly, and three of them can hurt you in CI.

First, uv does not read pip.conf or pip variables like PIP_INDEX_URL; it uses its own UV_DEFAULT_INDEX and uv.toml / [tool.uv.pip] configuration (the older UV_INDEX_URL still works but is marked deprecated in the docs — don't reach for it in a new setup). On a runner that supplies the internal index through an environment variable, this means silently falling back to PyPI — silently, because the install succeeds, just from the wrong place.

Second, uv pip compile requires you to name the output file explicitly (-o), applies --strip-extras by default, and omits index URLs from the output (you can ask for them with --emit-index-url). If you expect a file byte-identical to pip-tools' output, you'll meet diff noise instead.

Third, uv always installs into the active virtual environment; there is no --user support. To install into the system Python you have to say --system or set UV_SYSTEM_PYTHON=1. Pipelines that don't use a venv inside a container trip here.

And bytecode: uv skips generating .pyc files during installation by default. Installation gets faster, first startup gets slower. In short-lived containers you'll want --compile-bytecode.

One last warning, because it's this middle road's silent cost: uv pip compile doesn't generate hashes. I praised uv.lock's hashes above, but if you want the same guarantee on the pip interface you have to say --generate-hashes. Otherwise you take the speed and leave the integrity verification behind.

Python itself is a dependency too

This is the thing I ignored for a long time. requirements.txt locks packages, but not the interpreter they run on. A non-trivial share of "works on my machine" cases come not from a package version but from the difference between 3.11 and 3.13.

uv python pin 3.13
Enter fullscreen mode Exit fullscreen mode
Pinned `.python-version` to `3.13`
Enter fullscreen mode Exit fullscreen mode

uv searches for this file starting in the working directory and walking upwards, and honours the requires-python constraint in pyproject.toml for project commands. If a suitable version isn't on the system, its default behaviour is to download one; the python-preference setting defaults to managed: uv's own installations are preferred over system ones, but a suitable system Python is still preferred over downloading a new one. The upshot is that a new developer's setup instructions don't begin with "first find the right Python."

The part that pays off in CI

Speed on a developer machine is nice, but a dependency manager's real exam is CI. There's one critical command here:

uv sync --locked
Enter fullscreen mode Exit fullscreen mode

--locked errors out without installing anything if the lock file doesn't match the project metadata. I deliberately made it stale and got this:

error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
hint: To update the lockfile, run `uv lock`.
Enter fullscreen mode Exit fullscreen mode

Exit code 1. So it's a real gate, not a warning. If someone adds a dependency to pyproject.toml and forgets to commit uv.lock, the pipeline stops there. If you only want to check, uv lock --check exists; on my project it verified 57 packages in 14 milliseconds.

--frozen is a different thing: it uses the lock without questioning its freshness at all. Confusing the two means thinking you installed a gate while leaving the door open. In CI you want --locked.

On the GitHub Actions side there's an official astral-sh/setup-uv action, and the documentation calls pinning the uv version "best practice." I've written separately about why the action itself should be pinned to a commit SHA; the two are parts of the same discipline.

Two details matter here. First, I don't write enable-cache: as of v10.0.0 the action disables the cache by default on the pull_request_target, workflow_run and release triggers, explicitly to guard against cache poisoning. Setting it to true by hand means reopening a door that was closed for security; leave the auto default alone. Second, I use --all-groups rather than --all-extras: the accel set we built above is a dependency group, not an extra. With --all-extras it would never be installed and the tests would never see it — and you'd take months to notice.

- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d  # v10.0.1
  with:
    version: "0.12.6"
- run: uv sync --locked --all-groups
- run: uv run pytest tests
Enter fullscreen mode Exit fullscreen mode

In Docker the win comes from layer caching. The documented pattern separates dependencies from project code:

FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:0.12.6 /uv /uvx /bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
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
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked
ENV PATH="/app/.venv/bin:$PATH"
Enter fullscreen mode Exit fullscreen mode

UV_LINK_MODE=copy isn't arbitrary here: because the cache is a mount, copy-on-write linking can't be used and uv emits a warning. UV_COMPILE_BYTECODE=1 is a deliberate trade that lengthens installation and speeds up container startup.

Questions left open in production

Liking a tool is one thing; putting it inside a corporate network is another. What you need to answer before migrating:

The production sequel to the mistake this article opens with is this: during universal resolution uv can produce a lock even when no wheel exists for a platform you actually ship to. The pywin32 story repeats in reverse — the lock looks clean and the error surfaces on the server. Two settings close that gap: environments limits the resolution to the platforms you name, while required-environments requires wheels to exist for them.

[tool.uv]
environments = ["sys_platform == 'linux'", "sys_platform == 'win32'"]
required-environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"]
Enter fullscreen mode Exit fullscreen mode

Use the first if you only deploy to Linux, the second if you depend on C-extension packages. The accel group in this article (hyperscan, pdqhash) falls squarely into that class.

If you use a private index, use the [[tool.uv.index]] table. uv works with the first-index strategy by default: it stops at the first index where it finds a package. Unlike pip's behaviour of looking for the best version across all indexes, this closes the door on dependency-confusion attacks. You can ask for pip compatibility with --index-strategy unsafe-best-match — the word "unsafe" in the name amounts to advice.

If you fear version drift, the required-version field under [tool.uv] refuses to run when the uv version falls outside the range you specify. It's the cheapest way to be sure everyone on the team uses the same resolver.

From a supply chain angle, exclude-newer is an interesting tool: it limits resolution to distributions published before a given date, and it accepts durations too. So you can set up a cooling window like "never take a package within 7 days of its release." Given that newly published malicious versions are mostly caught in their first days, that's cheap insurance.

In air-gapped environments, uv downloads a Python version it can't find by default (python-downloads = "automatic"). Setting that to manual or never on a server without internet access removes a whole class of hard-to-diagnose failures up front.

And one point that demands honesty: I looked at the Python Packaging User Guide's tool recommendations page today, and uv isn't mentioned at all. The page still talks about pip, venv, pip-tools and Pipenv. uv is not the officially blessed default; but the standards it implements (PEP 735, PEP 751, PEP 723) are official. Decide knowing that distinction, not because "PyPA recommends it."

A small but unexpected win: single-file scripts

I didn't find this by searching for it; I found it while trying things. PEP 723 (status: Final) standardizes writing a script's dependencies inside the file itself:

# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx"]
# ///
import httpx, sys
print("httpx", httpx.__version__, "on", sys.version.split()[0])
Enter fullscreen mode Exit fullscreen mode
uv run --no-project demo.py
Enter fullscreen mode Exit fullscreen mode
Installed 6 packages in 3ms
httpx 0.28.1 on 3.13.3
Enter fullscreen mode Exit fullscreen mode

No environment, no install step, no "first install this" sentence in the README. Half the helper scripts on the ops side have for years either polluted the system Python or lived inside a venv nobody remembers. This closes that problem with a single comment block.

Migration checklist

  1. Review the platform assumption in every requirements file. If there's an unmarked Windows/Linux package, fix it before migrating — uv will tell you about that error to your face at install time.
  2. If you want to freeze versions, use the uv add -r <input> -c <resolved> form.
  3. Separate development/test dependencies with --dev and other optional sets with --group <name>; save extras for optional features you genuinely publish.
  4. Commit the uv.lock file. If we're not going to lock, the migration isn't worth making.
  5. Use uv sync --locked in CI, not --frozen.
  6. Add required-version to pyproject.toml and pin the uv version in CI.
  7. If you have to hand a dependency list to the outside world, generate it with uv export instead of maintaining it by hand.

Conclusion

At the end of the migration I was left with one pyproject.toml, one uv.lock and a CI gate that can't be talked out of. But what I actually learned from this migration isn't about uv. That pywin32 line had been there for months and it was wrong; what found it wasn't my attention, it was a stricter tool reading the same file.

The highest-return moment of switching tools is usually not the moment the new tool turns out to be fast; it's the moment the new tool can't ignore what the old one was ignoring. Once you turn dependency management from a hand-maintained habit into a description a machine can verify, your claims about your environment become testable for the first time. The speed is a bonus.

Official Sources

Top comments (0)