Introduction
Python's packaging story has been, for most of the language's history, a patchwork rather than a system. Where Node.js shipped npm alongside the runtime and Rust bundled cargo from day one, Python grew pip as an afterthought, standardized wheel and setup.py years after the fact, and left dependency resolution, virtual environment management, and build backends as separate concerns handled by separate tools. Anyone who has debugged a ModuleNotFoundError that only appears in CI, or spent an afternoon reconciling a requirements.txt with what was actually installed, has felt the cost of that fragmentation directly.
The last few years have changed this landscape more than the previous decade combined. A new generation of tools - most notably uv, developed by Astral, the same team behind the ruff linter - has attempted to unify environment management, dependency resolution, package installation, and even Python version management into a single, fast, Rust-based binary. This article walks through the current tool landscape, explains the technical reasons uv has become the default recommendation for new projects, and provides a concrete, opinionated guide to setting up a multi-environment Python project using it. It closes with the trade-offs, anti-patterns, and best practices that matter once a project moves past the toy stage.
The Fragmented State of Python Packaging
To understand why a consolidating tool like uv was even possible, it helps to understand what problem space existed before it. pip is the reference installer bundled with CPython since 3.4, and it does exactly one job: it resolves and installs packages into whatever Python environment is currently active. It does not manage virtual environments, it does not manage Python versions, and until relatively recently its dependency resolver used a backtracking algorithm that could be dramatically slower than tools built with resolution as a first-class concern. venv, virtualenv, pip-tools, and pyenv each filled in a piece of the gap - environment creation, environment creation with more features, lockfile generation, and Python version management, respectively - but a new project meant assembling four or five tools and gluing them together with a Makefile or a tox.ini.
Poetry, released in 2018, was the first widely adopted attempt at a unified experience. It introduced a single pyproject.toml-driven workflow, a real dependency resolver, and a lockfile (poetry.lock) that pinned exact versions across a project. PDM followed a similar philosophy but leaned harder into PEP 582 and, later, standards compliance with pyproject.toml's [project] table as defined in PEP 621. Hatch positioned itself as a build-backend-first tool with strong support for matrixed testing environments. Meanwhile, in the scientific and data engineering world, conda (and its faster reimplementation, mamba) solved a different problem entirely: managing non-Python dependencies like CUDA libraries, compilers, and system binaries that PyPI wheels cannot express. Pixi, built on the conda ecosystem but with a modern lockfile and CLI, is a newer entrant attempting to bring uv-like ergonomics to the conda world. Rye, an experimental project started by Armin Ronacher, prototyped many of the ideas - a single binary, Python version management, workspace support - that would later be absorbed into uv itself; Ronacher has since recommended uv as Rye's spiritual successor, and Rye is now in maintenance mode with its own documentation pointing users toward uv.
By 2023, the practical result was that a Python team choosing a packaging tool was really choosing a philosophy: full standards compliance and a stable resolver (Poetry, PDM), build-system flexibility (Hatch), or non-Python dependency management (conda, pixi). Each was mature, each had a loyal user base, and none was dramatically faster than the others, because all of them still shelled out to pip or a pip-derived resolver under the hood for the actual install step. That performance ceiling is exactly what uv was built to break.
How These Tools Actually Differ Under the Hood
The most important technical distinction between pip-family tools and uv is implementation language and architecture, not just feature checklists. pip, Poetry, PDM, and Hatch are Python programs. Their dependency resolvers, however cleverly written, run on the CPython interpreter and are bound by its performance characteristics for CPU-bound work like SAT-style version resolution. uv is written in Rust, uses a PubGrub-based resolver (the same resolution algorithm family used by Dart's pub and Cargo), and parallelizes network I/O aggressively when fetching package metadata and wheels. In practice this means dependency resolution and installation that historically took tens of seconds to minutes on a cold cache can complete in a few seconds, and warm-cache installs - the common case in CI and local development - are closer to instantaneous because uv hard-links or copy-on-write clones packages from a global cache rather than re-downloading and re-extracting them per virtual environment.
The second distinction is scope. uv does not just install packages; it also manages Python interpreter versions (replacing pyenv for many use cases), creates and manages virtual environments (replacing venv/virtualenv), generates and enforces lockfiles (replacing pip-tools' pip-compile), supports PEP 723 inline script metadata for single-file scripts with their own dependencies, and can build and publish packages (replacing parts of build and twine). This consolidation is architecturally different from Poetry or PDM, which still rely on the system or an external tool for Python version management in most setups. Conda and pixi take yet another approach: they maintain their own binary package repository (conda-forge for conda, with pixi able to consume both conda-forge and PyPI) so that non-Python artifacts - a specific BLAS implementation, a CUDA toolkit version, a compiled geospatial library - can be pinned and reproduced exactly, something wheel-based tools cannot do because PyPI wheels only describe Python-importable code and their own bundled shared libraries.
The third distinction, and the one that matters most for day-to-day engineering, is lockfile philosophy. Poetry's poetry.lock and PDM's pdm.lock are mature, well-tested, and support platform-specific markers. uv.lock, introduced with uv's project management features, is deliberately designed to be a universal lockfile: a single lockfile can represent resolutions for multiple Python versions and platforms simultaneously, marking which packages apply where, so that a monorepo with contributors on macOS, Linux, and Windows does not need per-platform lockfiles or a resolution step that silently differs by machine. This is a direct response to a common Poetry/PDM pain point where a lockfile generated on one platform could fail to install cleanly on another due to platform-specific wheel availability.
Finally, it's worth being precise about what uv is not. It is not a build backend in the way Hatchling, setuptools, or Flit are - uv still needs a build backend declared in pyproject.toml's [build-system] table to actually package your code into a wheel or sdist, and by default uv init scaffolds projects with hatchling as that backend. It's also not a replacement for conda in workflows that fundamentally depend on non-Python binary artifacts unless paired with something like pixi. Understanding this boundary prevents the common misconception that adopting uv means abandoning every other tool in the ecosystem; in most real setups it replaces pip, pip-tools, venv, and pyenv, while still deferring to a standards-compliant build backend for packaging.
Why uv Is Often the Default Choice for New Projects
Speed is the most visible reason uv wins default status, but it is not the deepest one. The deeper reason is that uv was designed from the outset to be a drop-in, standards-compliant replacement rather than an opinionated alternative universe. It reads and writes pyproject.toml using the PEP 621 [project] table that Poetry historically deviated from (Poetry has since added PEP 621 support but retained its own [tool.poetry] section for legacy reasons), it respects requirements.txt syntax for teams not ready to migrate fully, and its uv pip subcommand is a near drop-in replacement for existing pip invocations in scripts and Dockerfiles. This lowers the switching cost dramatically: a team can adopt uv incrementally, starting with uv pip install -r requirements.txt as a faster pip, and only later move to uv add and a proper uv.lock workflow.
Governance and maintenance velocity also matter for a tool selection decision that a team will live with for years. Astral, uv's maintainer, also maintains ruff, which has itself become the default Python linter and formatter for a large share of new projects, displacing Flake8, Black, and isort in many cases. That track record of shipping a Rust-based reimplementation of a Python tooling category, gaining wide adoption, and maintaining it actively gives engineering leads more confidence than betting on a smaller, single-maintainer project. This is a legitimate factor in tool selection even though it is not a purely technical one: packaging tooling sits underneath every CI pipeline and every developer's daily workflow, and churn there is expensive.
Finally, uv benefits from timing. It arrived after PEP 621 had stabilized the pyproject.toml schema, after wheels had become the dominant distribution format (making sdist-only build complexity rarer), and after the industry had collectively absorbed enough pain from slow resolvers and inconsistent lockfiles to be receptive to a tool that fixed both at once. A team starting a project in 2026 is not choosing between five roughly equivalent tools; they are choosing between a tool that unifies interpreter management, environment management, dependency resolution, and lockfiles with a documented performance advantage, versus tools that each solve a subset of that problem well. That asymmetry is why uv is increasingly the default recommendation in new-project templates, cookiecutters, and internal engineering standards documents, even among teams that are not otherwise inclined to chase new tooling.
Setting Up a Multi-Environment Project with uv
A "multi-environment" Python project typically means one of a few things in practice: separate dependency sets for development, testing, and production; support for multiple Python versions (say 3.11 and 3.12) in CI; or a workspace containing multiple installable packages that share some dependencies but not others. uv has first-class support for all three, and the right pattern depends on which problem you actually have.
The starting point is uv init, which scaffolds a pyproject.toml, a minimal source layout, and a .python-version file pinning the interpreter version for the project. From there, dependency groups - the standardized mechanism from PEP 735, which uv supports natively - let you separate production dependencies from development-only tooling like test runners and linters without needing Poetry's older, non-standard [tool.poetry.group] syntax:
# pyproject.toml
[project]
name = "orders-service"
version = "0.3.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastapi>=0.115",
"sqlalchemy>=2.0",
"pydantic>=2.9",
]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-cov>=6.0",
"ruff>=0.8",
"mypy>=1.13",
]
docs = [
"mkdocs-material>=9.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Installing the default environment plus the dev group is a single command, and uv resolves both together into one lockfile so that dev-tool version constraints can't silently conflict with production dependency constraints at install time:
uv sync --group dev
For teams that need to test against multiple Python versions - a common requirement for library authors, less common but still real for application teams supporting a Python upgrade window - uv integrates cleanly with tox or nox while handling interpreter provisioning itself. uv python install 3.11 3.12 downloads and manages both interpreters without touching system Python, and a noxfile.py can request each version explicitly:
# noxfile.py
import nox
@nox.session(python=["3.11", "3.12"])
def tests(session: nox.Session) -> None:
session.run_install(
"uv", "sync", "--group", "dev", "--python", session.python,
external=True,
)
session.run("pytest", "--cov=orders_service", "tests/")
For the workspace case - multiple internal packages, such as a shared common library and two services that depend on it, living in one repository - uv supports workspaces analogous to Cargo workspaces in Rust. A root pyproject.toml declares member packages, each member has its own pyproject.toml, and uv resolves dependencies across the whole workspace into one lockfile while still letting each member be installed or built independently:
# root pyproject.toml
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
common = { workspace = true }
This means orders-service in packages/orders-service can declare common as a regular dependency, and uv will resolve it to the in-repo path rather than trying to fetch it from PyPI, while still validating that its declared version constraints are satisfiable. This pattern is the single biggest practical improvement uv offers over hand-rolled pip install -e ../common scripts that many monorepos accumulate over time, because those scripts don't participate in lockfile resolution at all and routinely drift out of sync with what CI actually installs.
Trade-offs and Common Pitfalls
No tool is free of trade-offs, and uv's speed and consolidation come with real costs worth naming honestly. First, uv is young relative to Poetry and pip-tools, and while its core commands are stable, some edge cases around complex build backends, unusual sdist-only packages, and certain private-index authentication schemes have less battle-testing than the equivalent Poetry or pip workflows that have been in production for half a decade. Teams with heavily customized internal package indexes or legacy packages that don't build cleanly under modern PEP 517 backends should validate those specific cases before a full migration rather than assuming parity by default.
The most common anti-pattern teams fall into is treating uv purely as "fast pip" and never adopting its lockfile and dependency-group features, which discards most of its actual value and leaves the same reproducibility gaps that motivated the migration in the first place. A close second is mixing uv-managed environments with manual pip install calls inside an activated uv virtual environment - this desynchronizes the environment from uv.lock silently, and the next uv sync will either revert the manual change or produce a confusing diff, because uv sync treats the lockfile as authoritative and will remove packages not declared in it. Teams should also avoid committing a .venv directory or relying on uv's cache directory as a substitute for a committed uv.lock; the lockfile, not the cache, is what guarantees reproducibility across machines and CI runners. Finally, for projects with genuine non-Python native dependencies - CUDA-dependent ML packages, geospatial stacks built on GDAL, anything requiring specific system compiler toolchains - reaching for uv alone and fighting wheel availability is often the wrong call; pixi or conda remain the more appropriate tool for that dependency graph, and forcing a wheel-only solution onto a fundamentally binary dependency problem tends to produce fragile, platform-specific workarounds.
Best Practices for Real Projects
Commit uv.lock to version control unconditionally, the same way a team would commit package-lock.json or Cargo.lock. Treat it as the actual source of truth for what runs in production, and regenerate it deliberately with uv lock --upgrade rather than letting it drift through ad hoc installs. Pair this with uv sync --frozen in CI and Docker builds specifically, which fails the build if the lockfile is out of date relative to pyproject.toml rather than silently re-resolving - this single flag catches an entire category of "works on my machine" bugs where a developer added a dependency locally without updating the lockfile.
Pin the Python version explicitly with a .python-version file and let uv manage the interpreter rather than depending on whatever Python happens to be on the PATH of a given machine or CI runner. This removes an entire class of "which Python am I even using" debugging sessions and makes onboarding a new engineer a matter of running uv sync rather than a wiki page of manual setup steps. For Docker specifically, use the official uv Docker images or the documented multi-stage pattern that copies only the resolved virtual environment into the final image layer, which keeps build caches effective and final image sizes small - installing uv itself and running uv sync --no-dev in a builder stage, then copying /app/.venv into a slim runtime stage, is the pattern Astral's own documentation recommends.
Use dependency groups deliberately rather than a single flat dependency list with comments explaining which packages are "really" dev-only. Separate dev, test, docs, and lint groups (or whatever taxonomy fits the team) so that uv sync --no-dev in production installs strictly less than local development environments, reducing both image size and the attack surface of what's actually shipped. And for any project that will eventually be consumed by others - whether an internal shared library or a public package - keep the build backend choice (Hatchling is uv's sensible default, but setuptools or Flit remain valid) explicit and tested with uv build, since uv's speed advantages in resolution and installation don't remove the need to verify that the packaged artifact itself installs cleanly outside the development environment where it was built.
Key Takeaways
- Start new projects with
uv initand PEP 621-stylepyproject.toml; it's standards-compliant enough that migrating away later, if ever needed, isn't a rewrite. - Commit
uv.lockand enforce it in CI withuv sync --frozenso a stale lockfile fails the build instead of silently resolving differently. - Use
[dependency-groups]to separate dev, test, and docs dependencies from what actually ships to production. - Reach for
uv python installto pin interpreter versions per project instead of relying on system Python or a separately managedpyenvinstall. - Recognize when you actually need conda or pixi instead - real non-Python binary dependencies are a different problem than
uvis designed to solve.
Conclusion
The consolidation happening in Python packaging tooling mirrors what already happened in JavaScript's move toward pnpm and Rust's cargo-first ecosystem: a single, fast, standards-aligned tool absorbing what used to be four or five separately maintained utilities. uv earned its position as the default recommendation for new Python projects not by inventing new concepts, but by implementing existing standards - PEP 621, PEP 517, PEP 735 - faster and more coherently than the tools that came before it, while remaining compatible enough with pip-based workflows that adoption doesn't require a rewrite.
That said, defaults are starting points, not mandates. Poetry remains an excellent, mature choice for teams already invested in its workflow. Conda and pixi remain necessary, not optional, for projects with real native and scientific-computing dependencies. Hatch's matrix testing features are still genuinely distinctive. The right recommendation for a new project in 2026 is to start with uv unless there's a specific, identifiable reason not to - and to understand precisely what that reason is, rather than defaulting to an older tool out of habit.
References
- uv documentation, Astral - https://docs.astral.sh/uv/
- Astral, "uv: Python packaging in Rust" (project announcement and design rationale) - https://astral.sh/blog/uv
- PEP 621 - Storing project metadata in pyproject.toml - https://peps.python.org/pep-0621/
- PEP 517 - A build-system independent format for source trees - https://peps.python.org/pep-0517/
- PEP 735 - Dependency Groups in pyproject.toml - https://peps.python.org/pep-0735/
- PEP 723 - Inline script metadata - https://peps.python.org/pep-0723/
- Poetry documentation - https://python-poetry.org/docs/
- PDM documentation - https://pdm-project.org/
- Hatch documentation - https://hatch.pypa.io/
- conda documentation - https://docs.conda.io/
- pixi documentation, Prefix.dev - https://pixi.sh/
- pip-tools repository - https://github.com/jazzband/pip-tools
- Rye documentation (maintenance mode, recommending uv) - https://rye.astral.sh/
- Python Packaging User Guide - https://packaging.python.org/
- ruff documentation, Astral - https://docs.astral.sh/ruff/