I have been writing Python professionally for almost a decade, and for most of that time, packaging was the part of the language I most disliked. Every project I joined had a different setup ritual: requirements.txt plus pip, Pipfile plus pipenv, setup.py plus setuptools, poetry with its own lockfile, conda for some scientific subset of the team, and a sprinkling of pip-tools to keep the lock and the manifest aligned. Onboarding a new engineer involved reading two pages of README and apologising at least once.
That era is over. As of 2026, the Python packaging story is genuinely converged for the first time in my career. pyproject.toml is the source of truth. uv is the daily-driver tool that replaced pip plus venv plus pip-tools (and, for many teams, poetry too). pipx is the right way to install CLI tools globally without polluting any project's environment. The remaining edge cases (system-level dependencies, GPU-pinned wheels, monorepos with internal packages) have known answers.
The argument I want to make is that if you are still reaching for pip install -r requirements.txt and python -m venv as defaults, you are working two generations of tooling behind. The new stack is not a marginal improvement. It is faster (uv resolves and installs in seconds, not minutes), more correct (the lockfile is platform-aware), and simpler (one config file, one tool, two commands for 95% of the workflow). The transition is also gentle: every legacy file format keeps working, so you can migrate one project at a time.
The shape of the modern stack
Four pieces. Each one has a small surface area. Each one has obvious modern alternatives if you want them; the alternatives are mostly faster or slower, not more or less correct.
For a fresh project in 2026, the entire setup is:
Three commands. No virtualenv to remember to activate. No pip install -e . to remember. No requirements.txt to keep in sync with setup.py. The uv tool handles environment creation, dependency resolution, lockfile maintenance, and execution. The pyproject.toml it generates is the single declaration; the uv.lock it generates is the reproducible install instruction.
pyproject.toml: the single configuration file
The core insight of PEP 518 (2016) and PEP 621 (2020) was that Python projects needed one configuration format that could carry the metadata, the build instructions, and tool-specific config in one file. The format is TOML; the conventions have stabilised over the past few years.
Four sections do most of the work.
[project]is the standardised metadata. Every PEP 621 tool reads this section the same way.dependencieslists your runtime requirements.optional-dependenciesdeclares extras (pip install my-package[dev]).[project.scripts]declares console entry points. If you publish a CLI, this is where the binary name and entry function live.[build-system]tells PEP 517 builders what backend to use. Hatchling is a good modern default; setuptools still works, flit is fine, poetry-core is fine.[tool.X]sections are tool-specific config.[tool.uv],[tool.ruff],[tool.mypy],[tool.pytest.ini_options]all live here and the ecosystem reads them natively.
No more setup.py for new projects. No more setup.cfg. No more requirements.txt at the top of the project (uv reads from [project.dependencies] directly, generates uv.lock, and the lock is the source of "what gets installed"). One file, four sections, hundreds of tools that respect it.
uv: the tool that earned its place
uv is the project I held off recommending for a year, because I have been burned before by "this new tool is going to replace pip". uv is different. The numbers are real, the developer experience is dramatically better, and after running it on a half-dozen production projects for the better part of a year, I have not hit a regression I would have hit with pip.
The headline performance numbers, on a project with about 60 dependencies:
| Operation | pip + pip-tools | uv |
|---|---|---|
| Cold install (no cache) | 95s | 5s |
| Warm install (full cache) | 12s | 0.4s |
| Lockfile resolution | 18s | 1.2s |
| Upgrade resolution | 18s | 1.5s |
The speed comes from a Rust resolver, parallel network I/O, and a global cache that uv knows how to share across virtualenvs (a wheel for numpy 1.26 on the host is installed once and hardlinked into every project's venv). The first time the cache warms, uv is fast. The second time, every install is faster than pip on cached wheels.
The daily commands are short.
The uv run command is the one I use most. It runs the given command in the project's environment, ensuring the env exists and is in sync with uv.lock before launching. It replaces the source .venv/bin/activate && python script.py dance entirely. For scripts that have inline dependencies (PEP 723), you can run a single Python file with embedded requirements:
uv reads the inline metadata, creates a temporary environment, installs httpx, and runs the script. For one-off automation, this is shorter than maintaining a venv for the script.
pipx: where global tools belong
There is one category of Python software that does not fit the "per-project virtualenv" model: command-line tools you want available globally. mypy, ruff, pre-commit, pip-audit, cookiecutter, your own internal CLIs. Installing these into the system Python is dangerous (system tools can break); installing them into every project venv is wasteful.
pipx is the answer. It installs each CLI tool in its own isolated venv, exposes the binaries on PATH, and updates them in place. The mental model: "brew install for Python tools".
pipx is in the standard packaging recommendation now. The Python.org docs link to it. The uv team has a uv tool install command that does the same job with the same model (and shares the wheel cache). I have used both; either works. pipx has wider documentation and existed for longer; uv tool is faster and integrates with the rest of the uv stack. For a team standardising on uv, uv tool install is the consistent choice.
What to do with the legacy file formats
Four legacy formats, four migration paths.
setup.py (deprecated)
If the project still has a setup.py, the migration is to replace it with pyproject.toml and remove setup.py. Most metadata maps one-to-one (install_requires becomes [project.dependencies], extras_require becomes [project.optional-dependencies], entry_points.console_scripts becomes [project.scripts]). The build backend in [build-system] replaces the implicit setuptools dependency.
The edge cases that hold projects on setup.py: dynamic version computation, custom build steps, C extensions with non-trivial setup. For dynamic versioning, hatchling has [tool.hatch.version]. For C extensions, setuptools is still the right backend (declare it in [build-system]); the rest of the metadata still moves to [project]. For fully custom build logic, setup.py may stay, but the metadata still belongs in pyproject.toml per PEP 621.
requirements.txt (legacy)
A requirements.txt is a list of pinned versions. The modern equivalent is [project.dependencies] plus a uv.lock. The migration is mechanical: copy the package list (with version specifiers, not pins) into [project.dependencies], run uv lock, commit the resulting uv.lock. The pins are now in the lock file, not the manifest.
For multi-environment setups (requirements-dev.txt, requirements-prod.txt), use [project.optional-dependencies] with a group like dev or [tool.uv].dev-dependencies for non-published projects. uv sync --extra dev installs the dev dependencies; uv sync --no-dev skips them in production.
If a requirements.txt is referenced by external tooling (Docker, CI, deploy scripts), keep emitting one with uv export --format requirements-txt > requirements.txt until those references migrate. Do not maintain it as a source of truth.
Pipfile and Pipenv
pipenv is in maintenance mode. Migration is straightforward: copy the package list from Pipfile to pyproject.toml, run uv lock, delete Pipfile and Pipfile.lock. The differences between pipenv's lock format and uv's are real but converged on the same set of correctness properties.
poetry
poetry is the harder migration because more teams are still on it. The good news: poetry's pyproject.toml is mostly compatible with PEP 621 already. The bad news: the [tool.poetry] table differs from [project] in a few places (dependency syntax ^ vs PEP 440 specifiers, optional-dependency declaration). For most projects, the migration is rewriting the dependency section to use PEP 440 specifiers and switching [tool.poetry] to [project]. Some teams have stayed on poetry because the migration cost did not justify the speed gain; that is a defensible position. uv is faster and has fewer moving parts, but poetry is fine if you have it working.
CI and Docker, briefly
The modern Docker pattern for a Python service:
--frozen requires uv.lock to match pyproject.toml exactly (CI-safe; fails if the lock is out of date). --no-dev skips dev dependencies. The cache layer for pyproject.toml plus uv.lock rebuilds only when one of them changes; the rest of the project copies on top.
For multi-stage builds with smaller final images, install into a virtualenv in the build stage and copy it into the runtime stage. uv's uv sync can install into any virtualenv (UV_PROJECT_ENVIRONMENT=/opt/venv uv sync --no-dev), so the pattern is the same as with pip-based images, just faster.
The setup-uv action installs uv and caches the wheel store across runs. Wheels download once per repo, are reused on every subsequent run. CI minutes drop by an order of magnitude versus the pip-based equivalent.
Common pitfalls in CI
Most of the regressions I have seen migrating teams to uv are not bugs in the tool. They are configuration mistakes that pip's slowness used to mask. Faster installs surface the misconfigurations sooner, which is good, but they catch teams off guard the first time.
Cache the right thing. The instinct from pip-based CI is to cache .venv/. Do not do that with uv. Cache the uv global wheel store instead (~/.cache/uv on Linux, ~/Library/Caches/uv on macOS) and let uv sync --frozen rebuild the venv from the cached wheels each run. Caching .venv/ ties the cache to the runner's exact Python binary path, which breaks the moment the runner image updates. The wheel store is path-independent; it survives runner upgrades.
The astral-sh/setup-uv action handles this for you when you set enable-cache: true. If you are wiring uv into a custom CI image, mount ~/.cache/uv as the cache volume and you get the same result.
Use --frozen everywhere except locally. The flag refuses to update the lockfile and fails the run if pyproject.toml and uv.lock are out of sync. Without it, CI silently re-resolves dependencies on every run, which means a freshly-released transitive dependency can land in production without anyone reviewing the lock diff. I treat a CI failure on --frozen the same way I treat a git diff on a generated file: someone forgot to commit the regenerated lock, fix it now.
Sandbox the network the way production does. uv resolves and downloads from PyPI in parallel, which is wonderful until your CI runner is behind a corporate proxy that rate-limits. The fix is UV_HTTP_TIMEOUT (default 30s; bump to 120s for slow proxies) and UV_CONCURRENT_DOWNLOADS (default 50; drop to 8 if your proxy is angry about parallel connections). I have debugged "flaky CI" twice that turned out to be a proxy quietly dropping the 51st connection.
Separate the lock-check from the install. A pattern that works well in larger pipelines:
The first command runs in a fast "lint" job; the second runs in the test and build jobs. Splitting them gives you a clear failure message ("the lock is out of date") instead of a confusing one ("the install would have changed something").
Choosing a build backend
The [build-system] table is the one place in pyproject.toml where the choice still matters and the right answer is not always hatchling. Four options worth knowing.
hatchling is my default for new projects. It is fast, has good defaults, supports dynamic versioning out of the box ([tool.hatch.version] reading from __init__.py or VCS), handles src/ layout cleanly, and has the most active maintenance of the four. If you have no specific reason to choose otherwise, pick this.
setuptools is the right choice when you have C extensions, custom build steps, or are migrating an old project that already had a working setup.py. setuptools accepts a pyproject.toml configuration with a tiny stub setup.py (or none) for pure-Python projects, and it is the only backend with mature C extension support. The downside is performance; setuptools-based builds are noticeably slower than hatchling on cold builds.
flit-core is the minimalist option. It cannot build C extensions and does not support dynamic version resolution beyond reading __version__ from a module, but it has zero configuration for the common case. For a single-file utility package or a small pure-Python library, flit is hard to beat on simplicity.
poetry-core still makes sense if your team is on poetry and you publish from poetry's CLI. It builds wheels that are indistinguishable from setuptools or hatchling output, so consumers do not care. The reason to move off it is consistency: if you have switched to uv for installs but still have poetry-core as the backend, you have two tools in the loop instead of one.
A quick decision rule I use: pure-Python and no opinion picks hatchling; C extensions pick setuptools; one-file library picks flit; already on poetry and not migrating yet keeps poetry-core. Anything else is a smell, go back and pick one of these four.
Internal package indexes and private mirrors
The moment a team grows past a few engineers, someone needs to publish an internal Python package. The good news: every part of the modern stack supports private indexes. The bad news: the configuration surface area is wider than it needs to be, and the docs are scattered.
The modern way to declare an internal index in pyproject.toml:
explicit = true means uv only fetches from this index for packages that opt in ([tool.uv.sources] references this index by name). default = false keeps PyPI as the default for everything else. The combination prevents "dependency confusion" attacks, where an attacker uploads a package with the same name as your internal one to PyPI and your tooling silently prefers the public version.
Authentication is per-index env vars:
The naming pattern is UV_INDEX_<NAME_UPPERCASED>_USERNAME and _PASSWORD. In CI, store these as masked secrets; do not put them in pyproject.toml or .netrc checked into the repo.
For the underlying server, the two options I see in the wild are devpi (Python-specific, lightweight, easy to host) and Artifactory or JFrog (general-purpose, expensive, ubiquitous in larger enterprises). devpi is right for a team that wants a private mirror plus an internal package index in one binary; Artifactory is right when you already pay for it for other languages and want one credentials story across them.
If you are still on pip and want the same behaviour, the equivalent is --index-url plus --extra-index-url flags (or pip.conf). The trap is that pip will resolve from any configured index that has a matching package name, so the dependency-confusion protection requires --index-url (single source) plus a private mirror that proxies PyPI for you. uv's explicit-index model is materially safer.
When you still need pip
uv replaces pip for ~95% of what I do, but pip has a couple of niches it still owns.
Docker layer caching with vendored wheels. If you are building a Docker image in an air-gapped environment (no internet from the build host), the standard pattern is to download wheels into a directory on a build machine that does have internet, copy that directory into the image, and pip install --no-index --find-links=/wheels. uv supports the same flow (uv pip install --no-index --find-links=/wheels) but the documentation and tooling around vendored-wheel pipelines is still pip-first; many internal build systems assume pip syntax.
Bootstrapping inside a Dockerfile. The cleanest way to get uv into an image is pip install uv, then use uv from there. You need pip for one line. After that, pip does not have to run again, but it is the bootstrap.
Tools that embed pip directly. A few legacy tools (some IDE installers, some Jupyter kernel installers, older deploy scripts) shell out to pip install and expect a pip binary on PATH. Until those tools migrate, keep pip available in the environment. uv does not interfere with pip; both can coexist.
The rule I have settled on: pip stays installed, but no script I write calls it directly. If I see pip install in a fresh codebase, I read the surrounding context to understand whether it is one of the legitimate niches above, and replace it with uv if it is not.
The cases the modern stack does not cover well
No tool covers every Python use case. Three known gaps.
Conda-flavoured scientific work. If your team uses conda for numpy plus mkl plus cuda-pinned PyTorch, the conda ecosystem has its own packaging story (conda-forge, mamba/micromamba). uv does not replace conda for that workload. The reasonable path is conda env for the scientific base, then pip (or uv) for pure-Python adds inside the conda env. mamba is dramatically faster than classical conda and is the modern conda choice.
Editable installs in monorepos with custom build steps. uv supports editable installs (uv pip install -e .) and workspaces for monorepos with internal packages ([tool.uv.workspace]). For monorepos with non-trivial build steps (Bazel, Pants), the uv story is still maturing; teams in those situations usually stay with whatever build system already exists and use uv for the per-package venv only.
Compiled extensions outside the wheel ecosystem. If you depend on a C extension that does not publish a wheel for your platform, install will fall back to building from source, which requires the system toolchain. uv handles this the same way pip does (delegate to the build backend), but the underlying problem (no wheel) is not a uv problem to solve.
For these three cases, the modern tooling helps where it can and steps aside where it cannot. The vast majority of Python projects are not in any of these categories.
A recommended workflow for new projects
Here is the script I run when starting a new Python project in 2026.
Four minutes to a working project, with a lockfile, a pinned Python version, and a tested install path. The same setup with the legacy stack would take twice as long, produce two more files (requirements.txt, requirements-dev.txt), and require manual venv activation on every shell.
The migration order that survives CI
The Python packaging ecosystem has a specific and unusual property: it can absorb new tools without breaking old ones. Your team can adopt uv on one new project, prove it out for a quarter, and migrate other projects one at a time without forcing a coordinated big-bang. The lock formats, the pyproject.toml standard, the build-backend protocol; all of them allow gradual movement.
The path I recommend without reservation: use pyproject.toml as the source of truth on every new project, use uv for daily work and CI, use pipx (or uv tool) for global CLIs, and migrate legacy projects when you would have touched them anyway. Do not stop the world for a packaging migration. Do stop reaching for the legacy stack on greenfield work; the modern stack is unambiguously faster, more correct, and shorter.
The legacy stack is going to keep working for years. pip is fine, venv is fine, requirements.txt is fine. They are not the future, but they are not broken. The reason to move is not that the legacy stack is broken; it is that the new stack is dramatically better and the migration cost is low. After ten years of fighting Python packaging, that is not a sentence I expected to write in 2026, and yet here we are.
If you take one piece of practical advice from this entire piece, take this: pick one Python project this quarter, run uv init next to its existing setup (or just run uv lock against its current pyproject.toml), commit the lock file, and update CI to use uv sync --frozen. The migration is a single afternoon, the speedup is dramatic, and the project becomes the reference for the rest of the team. After that, the rest of the migrations are easy because everyone has seen one work end-to-end.
