Python Packaging Ecosystem

Python packaging has three distinct layers. Understanding which layer a tool lives in is the single most important thing for navigating this ecosystem, because people constantly confuse them.

┌─────────────────────────────────────────────────────┐
│              BUILD SYSTEMS (Monorepo)                │
│   Pants ──── Bazel ──── Buck2                       │
│   (orchestrates everything across a monorepo)       │
└───────────────────────┬─────────────────────────────┘
                        │ uses
┌───────────────────────▼─────────────────────────────┐
│         PROJECT / DEPENDENCY MANAGERS                │
│   uv ──── Poetry ──── PDM ──── Hatch ──── (Rye†)   │
│   (manage deps, venvs, lockfiles, Python versions)  │
└───────────────────────┬─────────────────────────────┘
                        │ delegates to
┌───────────────────────▼─────────────────────────────┐
│              BUILD BACKENDS (PEP 517)                │
│   hatchling ── setuptools ── flit ── poetry-core    │
│   maturin (Rust) ── pdm-backend ── scikit-build     │
│   (turn source code into distributable packages)    │
└─────────────────────────────────────────────────────┘

† Rye is deprecated, succeeded by uv

Build backends (bottom layer) turn source into wheels/sdists. You declare one in pyproject.toml and never invoke it directly. Project managers (middle) handle day-to-day workflow: resolving deps, managing venvs, locking versions, running scripts. Build systems (top) orchestrate entire monorepo pipelines — they call the project manager which calls the build backend.

The critical insight: you can mix and match across layers. uv (project manager) can use hatchling (build backend) while Pants (build system) orchestrates both. These are not competing tools — they’re complementary.


The Standards That Explain Everything

Every tool disagreement in this ecosystem traces back to PEPs (Python Enhancement Proposals). These are the ones that matter:

PEP 518 + PEP 517: The pyproject.toml revolution

Before 2017, setup.py was the only way to configure a Python package. It was executable Python, which meant you couldn’t parse it without running it — a security and tooling nightmare.

PEP 518 (2017) introduced pyproject.toml and the [build-system] table. PEP 517 defined a standard interface between project managers and build backends: any tool can call any backend through a fixed API (build_wheel(), build_sdist()). This is what decoupled setuptools from pip.

# This is PEP 517/518 in action. It says:
# "To build this package, install hatchling, then call its PEP 517 hooks"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

PEP 621: The metadata standard

The single most important PEP for understanding tool interoperability. Before PEP 621 (accepted 2021), every tool invented its own metadata format:

# Poetry (pre-2.0) — non-standard, Poetry-specific
[tool.poetry]
name = "myproject"
version = "1.0.0"
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
 
# PEP 621 — the standard everyone converged on
[project]
name = "myproject"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.28"]

The [project] table is now understood by uv, Poetry 2.0+, Hatch, PDM, pip, and every modern tool. If your pyproject.toml uses [project], you can switch between managers without rewriting metadata. If it uses [tool.poetry], you’re locked to Poetry.

PEP 660: Editable installs

Standardized pip install -e . for non-setuptools backends. Before this, editable installs only worked with setuptools. Now hatchling, flit, and others support it through a standard hook.

PEP 723: Inline script metadata

Allows dependency declarations inside standalone Python scripts. This is what powers uv run on scripts with no pyproject.toml:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "requests>=2.28",
#     "rich>=13.0",
# ]
# ///
 
import requests
from rich import print
print(requests.get("https://httpbin.org/ip").json())

Run with uv run script.py — uv reads the inline metadata, creates a temporary environment, installs deps, and runs the script. No project setup required.

PEP 582: The rejected experiment

Proposed replacing virtualenvs with a __pypackages__/ directory in the project root (like node_modules/). PDM was the primary implementation. The Python Steering Council rejected it — concerns about import system complexity and confusion with existing workflows. PDM still supports it as opt-in, but it’s a dead end.


Project / Dependency Managers

uv (Astral)

Created by Astral (same team behind Ruff). Written in Rust. First released February 2024. As of March 2026, dominant momentum — the tool most new projects reach for.

Philosophy: be the “Cargo for Python” — one fast binary that replaces pip, pip-tools, pipx, pyenv, virtualenv, and twine.

Starting a new project:

# Create a new project with hatchling backend
uv init myproject
cd myproject
 
# Or with maturin for Rust extensions
uv init myproject --build-backend maturin

This generates a minimal pyproject.toml:

[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
 
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Adding dependencies and locking:

uv add requests flask
uv add --dev pytest ruff
 
# This resolves all dependencies and writes uv.lock
# The lockfile is cross-platform — it records resolution
# for all platforms simultaneously, unlike Poetry's lockfile
# which can resolve differently on Linux vs macOS

The uv.lock format records platform-specific markers inline:

[[package]]
name = "cffi"
version = "1.16.0"
# Only needed on CPython (not PyPy, which has built-in cffi)
requires-python = ">=3.8"
sdist = { url = "..." }
wheels = [
    { url = "...-cp312-cp312-manylinux_2_17_x86_64.whl" },
    { url = "...-cp312-cp312-macosx_11_0_arm64.whl" },
    { url = "...-cp312-cp312-win_amd64.whl" },
]

One lockfile, all platforms. Poetry’s poetry.lock historically resolved per-platform, meaning poetry lock on macOS could produce a lockfile that failed on Linux.

Python version management:

# Install a specific Python version (downloads from Astral's builds)
uv python install 3.12
 
# Pin a project to a version
uv python pin 3.12
 
# List available versions
uv python list

This replaces pyenv entirely. The Python builds are pre-compiled, so installation takes seconds, not minutes of compiling from source.

Workspaces (monorepo support):

# Root pyproject.toml
[tool.uv.workspace]
members = ["packages/*"]
my-monorepo/
  pyproject.toml          # workspace root
  uv.lock                 # single lockfile for all packages
  packages/
    core/
      pyproject.toml      # [project] name = "my-core"
    api/
      pyproject.toml      # depends on my-core
    worker/
      pyproject.toml      # depends on my-core

All packages share one uv.lock. Inter-package dependencies resolve from the workspace, not PyPI. This is directly inspired by Cargo workspaces and pnpm workspaces.

What uv does NOT do:

  • No own build backend — it delegates to hatchling, setuptools, etc. uv init uses hatchling by default
  • No environment matrix — cannot run tests across Python 3.10/3.11/3.12 in one command (Hatch does this)
  • No plugin system
  • Publishing is basic (uv publish exists but is bare-bones compared to Poetry’s workflow)

Astral’s broader ecosystem:

  • Ruff — extremely fast Python linter/formatter (also Rust)
  • pyx — Python-native package registry launched August 2025 (still beta as of March 2026): private package hosting, accelerated frontend for PyPI, GPU-aware package distribution (critical for ML where torch is 2GB+), compliance filtering. Early partners: Ramp, Intercom, fal.

For more on uv internals (hybrid venvs, single-file deployment, script directives), see UV.

Poetry

Created by Sebastien Eustace. Written in Python. First released 2018. Current version: 2.x (2.0 released January 2025).

Philosophy: complete, opinionated, batteries-included. Poetry was the first tool to make Python packaging feel modern and it dominated from ~2019-2024.

Starting a project:

poetry new myproject
cd myproject
poetry add requests
poetry add --group dev pytest

The PEP 621 migration (Poetry 2.0):

Before 2.0, Poetry used its own metadata format:

# Poetry 1.x — non-standard
[tool.poetry]
name = "myproject"
version = "1.0.0"
description = "My project"
authors = ["Me <me@example.com>"]
 
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.28"
 
[tool.poetry.group.dev.dependencies]
pytest = "^7.0"

Poetry 2.0 moved to PEP 621:

# Poetry 2.0 — standard [project] table
[project]
name = "myproject"
version = "1.0.0"
description = "My project"
authors = [{name = "Me", email = "me@example.com"}]
requires-python = ">=3.10"
dependencies = ["requests>=2.28"]
 
[dependency-groups]
dev = ["pytest>=7.0"]
 
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

A migration plugin exists: poetry self add poetry-plugin-migrate then poetry migrate.

Poetry 2.0 also removed:

  • poetry shell — use poetry run or activate the venv manually
  • poetry self — replaced by pipx for managing Poetry’s own installation
  • poetry export — moved to a plugin (poetry-plugin-export)

Publishing workflow (Poetry’s strongest feature):

# Build sdist and wheel
poetry build
 
# Publish to PyPI
poetry publish
 
# Or build + publish in one step
poetry publish --build
 
# Publish to a private registry
poetry config repositories.private https://pypi.mycompany.com/simple/
poetry publish -r private

This is more polished than uv’s publishing. Poetry handles token management, repository configuration, and the build-test-publish cycle as a first-class workflow.

Weaknesses:

  • Slow dependency resolution — 3-10x slower than uv. On large projects with complex dependency trees, poetry lock can take minutes where uv lock takes seconds.
  • No Python version management — must install Python separately (pyenv, system package manager, etc.)
  • Lockfile not cross-platform by default — poetry lock resolves for the current platform. Running poetry lock on macOS may produce a lockfile that fails on Linux if platform-specific packages differ. (Workaround: poetry lock --no-update with CI matrix.)
  • poetry-core as build backend is another non-standard choice — it works, but hatchling is the PyPA-blessed default.

Hatch (Ofek Lev)

Created by Ofek Lev (PyPA member). Written in Python. Two components: Hatch (the CLI/project manager) and Hatchling (the build backend).

The officially recommended build tool by PyPA — the Python Packaging Authority, the standards body that governs pip, setuptools, and the PEP process.

Starting a project:

hatch new myproject
cd myproject

Generates:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
 
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = []

The environment matrix — Hatch’s killer feature:

No other project manager does this. You define environments with dependency matrices, and Hatch runs commands across all combinations:

[tool.hatch.envs.test]
dependencies = ["pytest", "pytest-cov"]
 
# Run tests across 3 Python versions × 2 dependency variants
[tool.hatch.envs.test.matrix]
python = ["3.10", "3.11", "3.12"]
 
[tool.hatch.envs.test.scripts]
run = "pytest {args}"
cov = "pytest --cov {args}"
# Runs pytest in 3 separate environments (3.10, 3.11, 3.12)
hatch run test:run
 
# Runs just the 3.12 variant
hatch run +py=3.12 test:run

This replaces tox and nox — standalone test automation tools that create virtualenvs for each Python version and run your test suite in each one. tox (2010, config via tox.ini) was the standard for years; nox (2017, config via Python code in noxfile.py) was the modern successor. Both are external tools you install separately. Hatch’s environment matrix builds the same capability directly into the project manager, so you don’t need a separate tool. The environments are lazily created — Hatch only builds an environment when you first run a command in it.

The lockfile question — what Hatch actually does:

Hatch deliberately has no lockfile. The philosophy (from Ofek Lev) is:

  • Libraries should declare loose version constraints (requests>=2.28) — a lockfile pins exact versions, which causes unnecessary conflicts when users install your library alongside other packages.
  • Applications need reproducible environments, but that is the deployer’s concern, not the build tool’s concern. Use pip freeze > requirements.txt or a separate tool.

In practice, this means:

# Hatch installs from [project].dependencies — whatever version
# the resolver picks today. Tomorrow it might pick a different version.
hatch env create
 
# There is no "hatch lock" command. Period.
 
# If you need reproducibility, Hatch expects you to either:
# 1. Use pip-compile (from pip-tools) to generate a pinned requirements.txt
# 2. Use uv alongside Hatch — uv for locking, Hatch for the environment matrix
# 3. Accept that for libraries, loose constraints are correct

This is why “migrate to Hatch” is hard for application developers. If you’re building a deployed service (not a library), you lose lockfile-based reproducibility unless you bolt on another tool. The combination uv (for locking) + hatchling (for building) is increasingly common as a workaround.

Dynamic versioning from VCS tags:

[project]
name = "myproject"
dynamic = ["version"]
 
[tool.hatch.version]
source = "vcs"

The version is read from git tags at build time. No manual version bumps, no version in source code. Tag v1.2.3, and the wheel is automatically versioned 1.2.3.

Hatchling as a standalone build backend:

Even if you use uv or Poetry as your project manager, you can use hatchling as the build backend:

# This pyproject.toml uses uv for dependency management
# but hatchling for building packages
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
 
[project]
name = "mylib"
version = "1.0.0"
dependencies = ["requests>=2.28"]

uv build will invoke hatchling. pip install . will invoke hatchling. The project manager doesn’t care — PEP 517 is the interface.

PDM (Frost Ming)

Created by Frost Ming (Mingda Fang), PyPA member. Written in Python. The “middle ground” tool — standards-compliant like Hatch, with a lockfile like Poetry.

pdm init
pdm add requests
pdm add -dG test pytest
pdm lock     # generates cross-platform pdm.lock
pdm install  # installs from lockfile

Centralized cache with hard links:

PDM stores installed packages in a global cache (~/.cache/pdm/packages/) and creates hard links into project venvs. If 10 projects use requests==2.31.0, the package exists once on disk with 10 hard links. This is the same approach as pnpm in the JavaScript world.

# See cache stats
pdm cache info
# Packages: 847
# Size: 2.1 GB (vs ~15 GB with per-project duplication)

User scripts:

[tool.pdm.scripts]
start = "python -m myapp"
test = "pytest tests/"
lint = {composite = ["ruff check .", "ruff format --check ."]}
pdm run start
pdm run test

Position in the ecosystem: PDM is well-engineered and well-maintained, but it’s being squeezed. uv is faster, has workspaces, and is absorbing PDM’s best ideas (cross-platform lockfile, PEP 621 native). PDM’s unique selling point (PEP 582 / __pypackages__) died when the PEP was rejected. For new projects in 2026, it’s hard to justify PDM over uv.

Rye (Armin Ronacher) — deprecated

Created by Armin Ronacher (creator of Flask, Jinja2, Click). Written in Rust. First released 2023. Deprecated — succeeded by uv.

Rye matters historically, not practically. Ronacher was frustrated with the fragmented Python packaging landscape and built Rye as a proof of concept. In February 2024, Astral took stewardship and absorbed Rye’s ideas — managed Python installations, global tool management, single-tool workflow — into uv.

If you encounter a project using Rye, migrate to uv: rye self update points to the migration guide.


Build Backends (PEP 517)

Build backends are declared in [build-system] and invoked automatically when you run uv build, poetry build, pip install ., etc. You rarely interact with them directly.

How the PEP 517 interface works:

The project manager calls two functions on the build backend:

# The build backend must expose these (simplified)
def build_wheel(wheel_directory, config_settings, metadata_directory):
    """Build a .whl file and return its filename."""
 
def build_sdist(sdist_directory, config_settings):
    """Build a .tar.gz source distribution and return its filename."""

This is why you can swap backends freely — uv doesn’t care if you use hatchling or setuptools, as long as the backend implements this interface.

BackendBest forConfig example
hatchlingPure Python packages. The PyPA default. Extensible via plugins.requires = ["hatchling"]
setuptoolsLegacy projects, C extensions. Still the most used despite its age.requires = ["setuptools>=68.0"]
flit-coreMinimal pure-Python packages. Almost zero config needed.requires = ["flit_core>=3.9"]
poetry-corePoetry users. Works but non-standard historically.requires = ["poetry-core"]
pdm-backendPDM users. PEP 621 native.requires = ["pdm-backend"]
maturinPython + Rust (PyO3). See below.requires = ["maturin>=1.0"]
scikit-build-corePython + C/C++/Fortran via CMake.requires = ["scikit-build-core"]

Maturin (Rust extensions)

Maintained by the PyO3 project. The standard way to ship Rust code as a Python package.

Why it exists: Building C extensions with setuptools is notoriously painful — compiler flags, platform-specific build scripts, linking issues. Maturin replaces all of that for Rust. You write Rust with #[pyfunction] attributes, and Maturin handles the rest.

Practical example — creating a Rust-powered Python package:

# Scaffold with uv
uv init my-fast-lib --build-backend maturin
cd my-fast-lib

This creates:

my-fast-lib/
  pyproject.toml
  src/
    lib.rs          # Rust source
  python/
    my_fast_lib/
      __init__.py   # Python wrapper
# pyproject.toml
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
 
[project]
name = "my-fast-lib"
requires-python = ">=3.10"
 
[tool.maturin]
features = ["pyo3/extension-module"]
// src/lib.rs
use pyo3::prelude::*;
 
#[pyfunction]
fn fast_sum(numbers: Vec<f64>) -> f64 {
    numbers.iter().sum()
}
 
#[pymodule]
fn my_fast_lib(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(fast_sum, m)?)?;
    Ok(())
}
# Development build — compiles Rust + installs into current venv
maturin develop
 
# In Python:
# >>> from my_fast_lib import fast_sum
# >>> fast_sum([1.0, 2.0, 3.0])
# 6.0
 
# Build wheels for distribution
maturin build --release
 
# Publish to PyPI
maturin publish

Why this matters: Ruff, uv, Polars, Pydantic v2, the cryptography package — the trend of rewriting Python bottlenecks in Rust is accelerating. Maturin is the standard bridge.


Build Systems (Monorepo)

Pants

Originally created at Twitter, circa 2010s. Python + Rust (engine is Rust-based). Same category as Bazel (Google) and Buck2 (Meta).

Pants is NOT a package manager. It is a build system that orchestrates your entire monorepo pipeline. It sits above uv/Poetry — it can use them for dependency resolution while managing the overall build graph.

How it actually works:

Pants analyzes your Python import graph at the file level, not the package level. If api/views.py imports from core.models import User, Pants knows api/views.py depends on core/models.py. This means:

# Only tests files that transitively depend on changed code
pants test --changed-since=main
 
# If you changed core/models.py, this runs tests for:
# - core/test_models.py (direct test)
# - api/test_views.py (because views.py imports models.py)
# - But NOT worker/test_processor.py (if it doesn't touch models.py)

Configuration:

# pants.toml
[GLOBAL]
pants_version = "2.20.0"
backend_packages = [
    "pants.backend.python",
    "pants.backend.python.lint.ruff",
    "pants.backend.python.typecheck.pyright",
]
 
[python]
interpreter_constraints = [">=3.10,<3.13"]
 
[python-repos]
indexes = ["https://pypi.org/simple/"]
# BUILD file (in each directory)
python_sources()
python_tests(name="tests")
 
python_distribution(
    name="my-core",
    dependencies=[":my-core"],
    provides=python_artifact(name="my-core", version="1.0.0"),
)
pants lint ::           # lint everything
pants test ::           # test everything
pants package ::        # build all distributions
pants fmt ::            # format everything
 
# The :: means "all targets, recursively"
# Pants figures out what actually needs to run

Remote caching:

# pants.toml
[GLOBAL]
remote_cache_read = true
remote_cache_write = true
remote_store_address = "grpc://cache.mycompany.com:9092"

If a colleague already ran the same test with the same inputs, Pants fetches the cached result instead of re-running it. This can cut CI times by 80%+ on large repos.

Pants vs Bazel for Python:

PantsBazel
Python supportFirst-class. Understands imports, resolves deps automatically.Requires rules_python. Manual BUILD file authoring.
Learning curveModerate. Python-native concepts.Steep. Starlark language, sandboxed execution model.
Automatic dep inferenceYes — reads import statementsNo — you declare all deps in BUILD files
Scale sweet spot10-500 engineers100-10,000+ engineers

When to use Pants: monorepo with 5+ Python packages that share code, CI taking 20+ minutes, polyglot codebase. When to skip it: single-package projects, small teams, or if uv workspaces cover your needs.


Publishing Packages to PyPI

Publishing is the part of the ecosystem where tools diverge most. Building a wheel is standardized (PEP 517 — any build backend works the same way). But uploading that wheel to PyPI (Python Package Index — the public registry at pypi.org) involves authentication, token management, and release workflows that each tool handles differently.

The underlying mechanism: twine

Historically, everyone used twine — a standalone upload tool:

# The "classic" workflow (still works, still common in CI)
pip install build twine
python -m build              # creates dist/mypackage-1.0.0.tar.gz and dist/mypackage-1.0.0-py3-none-any.whl
twine check dist/*           # validates metadata
twine upload dist/*           # uploads to PyPI

twine authenticates via:

  • ~/.pypirc file (legacy — stores credentials in plaintext)
  • TWINE_USERNAME + TWINE_PASSWORD env vars
  • PyPI API tokens (the modern approach — tokens start with pypi-)

Every project manager that “supports publishing” is ultimately doing some variant of: build the wheel, then upload it (either through twine or by reimplementing the PyPI upload API).

How each tool publishes

uv (basic but functional):

uv build                     # builds sdist + wheel into dist/
uv publish                   # uploads to PyPI

uv publish reimplements the upload protocol in Rust. It reads credentials from environment variables or prompts interactively:

# Via environment variables (typical in CI)
export UV_PUBLISH_TOKEN=pypi-AgEI...
 
# Or via a keyring
uv publish --token pypi-AgEI...
 
# Publish to a private registry
uv publish --publish-url https://upload.pypi.mycompany.com/legacy/

What uv does NOT do: no test-upload to TestPyPI as a built-in workflow, no ~/.pypirc integration, no interactive repository configuration. You handle those yourself. For a CI pipeline that just needs build + upload, this is sufficient. For a library author who regularly publishes with pre-release versions, test uploads, and multiple registries, it’s bare.

Poetry (the most polished):

# Configure a repository (stored in Poetry's config, not pyproject.toml)
poetry config pypi-token.pypi pypi-AgEI...
 
# Build and publish in one step
poetry publish --build
 
# Publish to TestPyPI first (a common pre-release safety check)
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry config pypi-token.testpypi pypi-AgEI...
poetry publish --build -r testpypi
 
# After verifying on TestPyPI, publish to real PyPI
poetry publish --build

Poetry manages repository configuration and credentials persistently — you run poetry config once and it remembers. It also validates the package before upload and provides clear error messages for common issues (version already exists, invalid metadata, etc.). This is why library authors who publish frequently still reach for Poetry.

Hatch:

hatch build                  # builds into dist/
hatch publish                # uploads to PyPI
hatch publish -r test        # uploads to TestPyPI

Hatch’s publishing is solid — it supports multiple repositories, API tokens, and has a clean CLI. It’s between uv (minimal) and Poetry (full-featured).

Maturin (for Rust extensions):

maturin publish              # builds Rust, creates wheel, uploads to PyPI
maturin publish --repository testpypi

Maturin handles the entire chain: compile Rust → build wheel with native extension → upload. For Rust+Python packages, this is the only publishing workflow that makes sense.

What people actually do in 2026

The most common patterns:

  1. uv + GitHub Actions — for most projects. Build and publish in CI, not locally. The CI job does uv build && uv publish with a UV_PUBLISH_TOKEN secret. Simple, fast.

  2. Poetry for library authors — people who publish 5+ packages and want persistent registry config, TestPyPI workflows, and polished error handling. They may use uv for everything else and switch to poetry publish for releases.

  3. Trusted Publishers (OIDC) — the modern best practice. PyPI supports OpenID Connect, where GitHub Actions (or other CI) authenticates directly with PyPI using short-lived tokens — no stored API tokens at all:

# .github/workflows/publish.yml
# No token needed — GitHub proves identity to PyPI via OIDC
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write    # Required for OIDC
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv build
      - uses: pypa/gh-action-pypi-publish@release/v1
        # No token parameter — OIDC handles authentication

You configure this once in PyPI’s project settings (“Add a trusted publisher”) by linking your GitHub repo. After that, any CI run from that repo can publish without any stored secrets. This is the direction the ecosystem is moving.

  1. Maturin for Rust extensionsmaturin publish in CI, often with a matrix of target platforms (Linux, macOS, Windows × x86, ARM) to build wheels for all platforms.

Practical: Choosing a Tool in 2026

New single-package project (application): Use uv with hatchling. You get fast resolution, cross-platform lockfile, Python version management, and PEP 723 scripts.

uv init myapp
cd myapp
uv add flask sqlalchemy
uv lock
uv run flask run

New library to publish on PyPI: Use uv for dependency management + hatchling for building. If you need Poetry’s polished publishing workflow, use Poetry 2.0 instead.

Monorepo with shared packages: Use uv workspaces if all packages are Python. Use Pants if you have a polyglot codebase (Python + Go + Protobuf) or need remote caching.

Python + Rust extension: Use uv + maturin. uv init --build-backend maturin gives you the scaffold.

Testing across Python versions: Use Hatch for its environment matrix, or combine uv with a CI matrix (strategy.matrix.python-version).

Existing Poetry project: No urgent need to migrate if it’s working. When you do, move to uv — the pyproject.toml is already PEP 621 if you’re on Poetry 2.0, so you mainly need to regenerate the lockfile:

# In an existing Poetry 2.0 project
rm poetry.lock
uv lock          # generates uv.lock from the same pyproject.toml
uv sync          # installs from the new lockfile
# Test thoroughly, then remove [tool.poetry] sections if any remain

Existing Poetry 1.x project: First migrate to Poetry 2.0 (poetry self add poetry-plugin-migrate && poetry migrate), then optionally to uv as above.

See also

  • UV — detailed uv features (hybrid venvs, single-file deployment, inline script metadata)
  • Modern Javascript Tooling — similar ecosystem evolution in the JS world