Python packaging has a reputation. Not a good one. For years, the ecosystem looked like an archaeological dig — setup.py, setup.cfg, requirements.txt, MANIFEST.in, tox.ini, .python-version, and a Pipfile thrown in for good measure. Seven files doing the job of one. Every project slightly different, every tutorial from 2019 subtly wrong in 2024, and completely wrong now.
That era is over. pyproject.toml is the standard, and the tooling around it has matured to the point where you can set up a publishable, testable, reproducible Python package in under ten minutes. The question is no longer "how do I package Python code" — it’s "which tool fits my workflow."
This article covers the four things you actually need to know: the spec (pyproject.toml), and the three serious build backends competing for your default — Flit, PDM, and Hatch. We’ll go through what each one does, when to use it, and where each will bite you.
pyproject.toml: The Only File You Need
pyproject.toml was standardized by PEP 517 and PEP 518 and finalized in spirit by PEP 621. It describes your project metadata in a single, tool-agnostic format.
The file has three logical sections: [build-system], [project], and tool-specific tables like [tool.hatch] or [tool.pdm].
Here’s the minimal skeleton every Python project should start from:
[build-system]
requires = ["hatchling"] # or "flit-core", "pdm-backend", etc.
build-backend = "hatchling.build"
[project]
name = "mypackage"
version = "0.1.0"
description = "Does one thing, does it well"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [
{ name = "Nikita Bezmen", email = "[email protected]" }
]
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
]
[project.urls]
Homepage = "https://github.com/yourname/mypackage"
Repository = "https://github.com/yourname/mypackage"
The [build-system] block is the key. It tells pip and build which backend to use. Swap out hatchling for flit_core or pdm-backend and the rest of the [project] table stays identical. That’s the whole point — the metadata is portable, the backend is interchangeable.
Gotcha:
versioncan be a string literal or dynamically generated. If you writeversion = "0.1.0"and also tell your backend to read version from__init__.py, you’ll get a conflict. Pick one source of truth and commit to it.
Flit: The Minimalist
GitHub: https://github.com/pypa/flit
Flit’s philosophy is ruthless simplicity. If your project is a pure-Python library with no compiled extensions, Flit will get you from "file on disk" to "published on PyPI" faster than anything else. No virtual environment management, no task runner, no plugin system. Just build and publish.
Install and initialize
pip install flit
flit init
flit init walks you through a short wizard and spits out a pyproject.toml. By default it uses flit-core as the build backend, which is minimal, fast, and part of the PyPA umbrella project.
Full Flit config example
[build-system]
requires = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"
[project]
name = "mylib"
version = "0.4.2"
description = "Tiny utility library for HTTP retries"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.11"
authors = [{ name = "Nikita Bezmen", email = "[email protected]" }]
dependencies = ["httpx>=0.27"]
[project.urls]
Source = "https://github.com/yourname/mylib"
# Tell Flit where your package lives
[tool.flit.module]
name = "mylib"
Publishing is a one-liner:
flit publish
Flit reads your ~/.pypirc or prompts for credentials. That’s it. No extra build step, no twine upload dance.
When Flit makes sense
Use Flit when you’re maintaining a library and you want to stay out of tooling rabbit holes. A data scientist writing reusable utilities, a team sharing internal packages via a private registry, a solo dev who just wants flit publish and done.
Gotcha: Flit has zero virtual environment management. It does not understand lock files. If you need reproducible dev environments or workspace features, Flit will leave you stranded. You’ll end up reaching for
venvmanually and losing the "single tool" benefit. Flit is a build-and-publish tool, not a project manager.
PDM: The Power User’s Choice
GitHub: https://github.com/pdm-project/pdm
PDM is the most feature-complete tool in this space. It manages virtual environments, resolves and locks dependencies, supports PEP 582 (local __pypackages__, though that PEP was withdrawn), handles workspaces for monorepos, and includes a task runner. If you’ve used pnpm or Cargo, PDM will feel familiar.
pip install pdm
pdm init
pdm init asks about your Python version and whether you want it to create a venv. Answer yes to everything the first time.
Full PDM config example
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[project]
name = "myservice"
version = "1.2.0"
description = "Internal microservice for async job dispatch"
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Nikita Bezmen", email = "[email protected]" }]
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"pydantic>=2.7",
"sqlalchemy>=2.0",
]
[tool.pdm.dev-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"ruff>=0.4",
"mypy>=1.10",
]
[tool.pdm.scripts]
serve = "uvicorn myservice.main:app --reload"
test = "pytest tests/ -v"
lint = "ruff check . && mypy ."
Now your common tasks become:
pdm install # installs all dependencies into the managed venv
pdm run serve # starts dev server
pdm run test # runs tests
pdm add httpx # adds dependency and updates lockfile
pdm update # updates everything within constraints
pdm publish # builds and pushes to PyPI
The pdm.lock file is analogous to package-lock.json — it pins every transitive dependency with hashes. Commit it. Your CI will thank you.
Workspace support (monorepo)
PDM handles monorepos cleanly. If you have packages/api and packages/worker that share internal libraries:
# Root pyproject.toml
[tool.pdm.workspace]
packages = ["packages/*"]
Each sub-package has its own pyproject.toml. pdm install wires them together with editable installs automatically.
Gotcha: PDM’s virtual environments are stored inside the project by default (
__pypackages__style or.venv). If you runpythondirectly withoutpdm run, you’re using the system Python. Always usepdm run <cmd>or activate the venv first. Forgetting this in CI is a classic footgun.
Gotcha: The lockfile format is PDM-specific. If another developer on your team uses a different tool, they can still
pip install -e .using thepyproject.tomlconstraints, but they lose the pin precision. Make sure everyone on the team actually uses PDM if you’re relying on the lockfile for reproducibility.
Hatch: The Modern Standard
GitHub: https://github.com/pypa/hatch
Hatch is the tool I’d recommend to most teams right now. It’s maintained under the PyPA umbrella (same organization that maintains pip), its build backend hatchling is the default for new projects in several major frameworks, and it has a clean environment model that avoids the "which venv am I in" confusion.
The key concept in Hatch is environments. Each environment is isolated, has its own dependencies, and maps to a matrix of tasks. You define your dev environment, your lint environment, your test matrix — all in pyproject.toml.
pip install hatch
hatch new myproject
hatch new scaffolds a complete project structure: src/ layout, tests/, pyproject.toml, README.md, .gitignore. The src/ layout is the right default and Hatch pushes you toward it by default, which is good practice.
Full Hatch config example
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "myapp"
dynamic = ["version"] # version read from __about__.py
description = "API gateway with JWT auth"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{ name = "Nikita Bezmen", email = "[email protected]" }]
dependencies = [
"fastapi>=0.111",
"pydantic>=2.7",
"python-jose[cryptography]>=3.3",
]
[project.optional-dependencies]
test = [
"pytest>=8.0",
"pytest-cov>=5.0",
"httpx>=0.27",
]
lint = [
"ruff>=0.4",
"mypy>=1.10",
]
# Dynamic version from src/myapp/__about__.py
[tool.hatch.version]
path = "src/myapp/__about__.py"
# Default dev environment
[tool.hatch.envs.default]
dependencies = [
"pytest>=8.0",
"httpx>=0.27",
"ruff>=0.4",
]
[tool.hatch.envs.default.scripts]
test = "pytest tests/ -v --cov=myapp"
lint = "ruff check . && ruff format --check ."
fmt = "ruff format ."
# Test matrix across Python versions
[tool.hatch.envs.test]
dependencies = ["pytest>=8.0", "pytest-cov>=5.0"]
[[tool.hatch.envs.test.matrix]]
python = ["3.11", "3.12", "3.13"]
Running things:
hatch run test # runs tests in the default env
hatch run lint:check # if you have a named lint env
hatch run test:pytest # runs pytest in the test matrix env
hatch build # builds sdist and wheel
hatch publish # publishes to PyPI
hatch env show # lists all environments and their status
The version file src/myapp/__about__.py just needs one line:
__version__ = "1.0.0"
Hatch reads it, injects it into the built package metadata. Bump the string, rebuild, done.
Why the src/ layout matters
Hatch defaults to placing your package under src/myapp/ rather than myapp/ at the root. This prevents a subtle but real bug: if you run pytest from the project root with a flat layout, Python can import from the local directory instead of the installed package. This masks import errors that only surface in production. The src/ layout forces Python to use the installed package, making your test environment match what end users actually get.
Gotcha: Hatch does not generate a lockfile. It’s intentional — Hatch’s philosophy is that lock files belong to applications, not libraries, and
pip‘s resolver handles application deployment. If you need locked installs for a deployed service, combine Hatch (for building) withpip-compilefrompip-toolsfor generating pinnedrequirements.txtfiles for production.
Comparison: Which Tool for Which Job
| Feature | Flit | PDM | Hatch |
|---|---|---|---|
| Venv management | No | Yes | Yes |
| Lockfile | No | Yes | No |
| Task runner | No | Yes | Yes |
| Monorepo/workspace | No | Yes | Partial |
| Test matrix | No | No | Yes |
| Publish to PyPI | Yes | Yes | Yes |
| Under PyPA | Yes | No | Yes |
| C extensions | No | No | Yes (plugins) |
| Learning curve | Very low | Medium | Low-Medium |
The honest take: Flit if you maintain small pure-Python libraries and want zero overhead. PDM if you’re building applications, have a monorepo, or need lockfile-driven reproducibility. Hatch if you’re writing libraries that need to be tested against multiple Python versions and you want official PyPA tooling.
There is no wrong answer among these three. The wrong answer is still reaching for setuptools with a setup.py in 2026.
Production-Ready: CI with Hatch
Here’s a GitHub Actions workflow that uses Hatch to test against a Python version matrix — the same matrix you defined locally:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Hatch
run: pip install hatch
- name: Run tests
run: hatch run test:pytest
# Hatch creates the venv for the current Python automatically
- name: Lint (only on latest Python)
if: matrix.python-version == '3.13'
run: hatch run lint
No requirements.txt needed. No virtualenv setup steps. Hatch handles all of it.
Production-Ready: Publishing a Package
All three tools support publishing with a single command, but the credentials flow is the same. Set these environment variables in your CI secrets:
HATCH_INDEX_USER=__token__
HATCH_INDEX_AUTH=pypi-AgEIcHlwaS5vcmcx...
# Or for PDM:
PDM_PUBLISH_USERNAME=__token__
PDM_PUBLISH_PASSWORD=pypi-AgEIcHlwaS5vcmcx...
Always use API tokens, not your PyPI password. Go to pypi.org → Account Settings → API tokens → create a token scoped to your specific project. Never put the token in the repository.
Gotcha: The
dynamic = ["version"]pattern in Hatch is extremely useful but watch for version drift. If__about__.pysays1.0.0and you forget to bump it beforehatch publish, you’ll try to upload a version that already exists on PyPI and get a 400 error. Automate version bumping withhatch version patch(bumps patch),hatch version minor, etc.
The Tools You Can Ignore
You’ll see Poetry mentioned constantly. It’s not on this list deliberately. Poetry uses its own metadata format that doesn’t fully conform to PEP 621, its resolver has historically been slow and has had correctness issues on complex dependency graphs, and it’s outside the PyPA umbrella. It’s improved significantly but the PEP 621 non-compliance is still a structural problem. If you’re already using Poetry and happy with it, staying is reasonable. Starting a new project with it in 2026 is not the move.
setup.py is a program that happens to configure a package. It was powerful but the power was the problem — every project abused it differently. Eliminate it.
pip-tools (pip-compile + pip-sync) is still excellent for the specific task of generating pinned requirements files. It’s not a build tool, it’s a dependency pinning tool. Combine it with Hatch if you need deterministic application deploys.
The State of Things
The Python packaging story is genuinely good in 2026. pyproject.toml has unified the metadata layer. The three tools above cover every use case from "simple library" to "complex monorepo." The build pipeline — pyproject.toml → backend → wheel → PyPI — is standard and well-understood.
Pick Hatch for new projects unless you have a specific reason for PDM. Read the tool’s documentation for the one you choose — all three are well-documented. Commit your pyproject.toml. Delete your setup.py. You’re done.