Build & Distribution
Building turns a Python project into wheel and sdist artifacts; distribution is how those artifacts reach the people and systems that will install them.
Build and distribution are the two steps after a project is packaged: building compiles a source tree into standard distribution artifacts (wheels and source distributions), and distribution is how those artifacts get onto an index or into someone else's environment. This topic goes deeper on the mechanics of both formats and the process of publishing, building on the packaging fundamentals from Packaging Python Projects.
The Wheel Format in Depth
A wheel is a zip file with a .whl extension and a strict internal structure: the package's importable files, a <dist>-<version>.dist-info/ directory holding METADATA (the project's metadata as flat text), RECORD (a manifest of every installed file and its hash, used for clean uninstalls), and WHEEL (build metadata). Because a wheel already has files in their final installed layout, pip install from a wheel is essentially 'unzip into site-packages plus write metadata' — no code execution or compilation is needed at install time.
weatherkit-0.1.0-py3-none-any.whl (unzipped)
├── weatherkit/
│ ├── __init__.py
│ └── client.py
└── weatherkit-0.1.0.dist-info/
├── METADATA
├── RECORD
└── WHEELThe Sdist Format in Depth
A source distribution is a .tar.gz archive containing the project's source files, pyproject.toml, and a PKG-INFO metadata file. Unlike a wheel, it is not directly installable as-is — installing from an sdist means the build backend runs on the target machine to produce a wheel first, then that wheel is installed. This makes sdists slower to install but necessary as a fallback whenever no matching pre-built wheel exists (e.g. an unusual platform for a compiled package), and useful as the canonical 'this is the real source' artifact.
The Build Process
PEP 517 defines a standard interface between a *build frontend* (a tool that orchestrates building, like the third-party build package, or pip itself when it needs to build something) and a *build backend* (the tool named in pyproject.toml's [build-system] that does the actual work). The frontend calls backend hook functions like build_wheel() and build_sdist() in an isolated environment, without needing to know anything backend-specific.
By default, build creates a fresh isolated virtual environment to run the build in, guaranteeing the build only sees the dependencies it declared in [build-system] requires, not whatever happens to be installed in your working environment.
python -m pip install build
python -m build # builds BOTH sdist and wheel by default
python -m build --wheel # wheel only
python -m build --sdist # sdist onlyPublishing Concept
Publishing uploads built artifacts (wheel and/or sdist from dist/) to a package index so others can pip install your project by name. The standard third-party tool for this is twine, which uploads to PyPI (or a private index) using an API token rather than a password.
twine is a third-party/ecosystem tool, not part of the standard library. PyPI accepts uploads only via API tokens (not plain passwords) for security.
python -m pip install twine
python -m build
twine check dist/* # validate metadata before uploading
twine upload dist/* # upload to PyPI (prompts for an API token)
# Uploading to TestPyPI first, to rehearse a release safely:
twine upload --repository testpypi dist/*Package Indexes
PyPI (pypi.org) is the default public index and the target of a normal pip install <name>. TestPyPI is a separate, disposable instance used to rehearse uploads without touching the real index. Organizations also run private indexes (self-hosted or via third-party hosting) for internal-only packages, configured on the installing side via --index-url/--extra-index-url (see the pip topic for the dependency-confusion risk of mixing indexes).
Versioning
Python version numbers are governed by PEP 440, which is compatible with but broader than Semantic Versioning (SemVer) — the common MAJOR.MINOR.PATCH convention where a major bump signals breaking changes, minor adds backward-compatible features, and patch fixes bugs without changing the API.
Bumping version in pyproject.toml for every release (or generating it dynamically from a VCS tag via the build backend) is what lets pip install --upgrade and version specifiers behave meaningfully.
1.0.0 stable release
1.1.0 new backward-compatible feature
1.1.1 bug fix, no API change
2.0.0 breaking change
2.0.0a1 alpha pre-release
2.0.0rc1 release candidate
1.0.0.post1 post-release (packaging fix, no code change)
1.0.0.dev3 development releasePractical Workflow
A realistic release sequence for an open-source-style project:
1. Bump version in pyproject.toml (e.g. 0.1.0 -> 0.2.0)
2. Update CHANGELOG
3. python -m build
4. twine check dist/*
5. twine upload --repository testpypi dist/* # rehearsal
6. pip install --index-url https://test.pypi.org/simple/ mypkg # verify
7. twine upload dist/* # real release
8. git tag v0.2.0 && git push --tagsEdge Cases
A package name is claimed globally and permanently the first time a release under it is uploaded to PyPI — a name collision means choosing a different name, not overwriting someone else's project. PyPI also disallows re-uploading a file for a version number that was already published, even after deletion — versions are meant to be immutable once released, so a broken release should get a new version number rather than being overwritten. A pure-Python package that accidentally builds a platform-specific wheel (or vice-versa) usually indicates a build backend misconfiguration worth double-checking with twine check and by inspecting the wheel's filename tags.
Best Practices
Always build into a clean dist/ (delete stale artifacts first) so you never accidentally upload an old build. Run twine check before every upload. Rehearse on TestPyPI for anything unfamiliar. Tag releases in version control to match published versions. Prefer PEP 440-compliant, SemVer-flavored version numbers so both humans and pip's resolver interpret them the way you intend.
Complete Example
rm -rf dist/ build/ *.egg-info # clean slate
python -m build # -> dist/mypkg-1.2.0-py3-none-any.whl, dist/mypkg-1.2.0.tar.gz
twine check dist/*
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ --no-deps mypkg==1.2.0
# looks good -> ship it for real:
twine upload dist/*Quick Reference
python -m build build wheel + sdist into dist/
python -m build --wheel wheel only
twine check dist/* validate metadata before upload
twine upload dist/* publish to PyPI
--repository testpypi target TestPyPI instead
Wheel: pre-built, fast install, no build step needed
Sdist: source only, built by the installer's own backend
MAJOR.MINOR.PATCH breaking . feature . fix (SemVer-style, PEP 440 compatible)Key Takeaways
Building produces two complementary artifacts — a fast-to-install wheel and a from-source sdist fallback — via a PEP 517 frontend/backend split.
python -m build is the standard, backend-agnostic build command; twine (third-party) is the standard upload tool.
TestPyPI exists specifically to rehearse uploads without affecting the real index.
Published versions are effectively immutable — fix a bad release with a new version number, not a re-upload.
Version numbers follow PEP 440 and are commonly SemVer-flavored: MAJOR for breaking changes, MINOR for features, PATCH for fixes.
python -m build # dist/ still has old artifacts from a previous version twine upload dist/* # accidentally re-uploads/mismatches an old build rm -rf dist/ python -m build twine upload dist/*
A stale dist/ directory can mix artifacts from different versions; clean it before every build to guarantee you upload exactly what you just built.
twine upload --repository testpypi dist/* # verify install works, THEN: twine upload dist/*
PyPI releases are effectively permanent once published; rehearsing on TestPyPI catches configuration mistakes without burning a real version number or namespace.
PyPI does not allow overwriting a previously published version's files — ship the fix as a new version.
Why is installing from a wheel faster than installing from an sdist?
A wheel already contains files in their final installed layout, so pip just unpacks it; an sdist contains only source and pyproject.toml, requiring the build backend to run and produce a wheel before installation can complete.
What is the relationship between a build frontend and a build backend under PEP 517?
The frontend (e.g. the build package, or pip when it needs to build something) orchestrates the process and calls standardized hook functions on whichever backend pyproject.toml names, without needing backend-specific knowledge.
What is TestPyPI for, and why use it before a real release?
It's a separate, disposable instance of the package index used to rehearse uploads and installs without affecting the real PyPI index or permanently consuming a package name/version.
Why can't a broken release simply be re-uploaded under the same version number?
PyPI treats a published version's files as immutable once uploaded (even after deletion, that version number can't be reused) — the fix must ship as a new version number.
In SemVer-flavored PEP 440 versioning, what does bumping the MAJOR number signal?
A breaking, backward-incompatible change to the public API, as opposed to MINOR (new backward-compatible features) or PATCH (bug fixes with no API change).
What third-party tool is used to upload build artifacts to PyPI, and what does twine check do?
twine is the standard upload tool; twine check dist/* validates a distribution's metadata (e.g. that long-description renders correctly) before it's uploaded.
Write the shell commands to clean any previous build artifacts, build both wheel and sdist, and validate them with twine before uploading.
rm -rf dist/ build/ *.egg-info python -m build twine check dist/*
Removing stale dist/build directories prevents uploading artifacts from an old version; python -m build produces fresh wheel and sdist files; twine check validates their metadata before any upload is attempted.
What does the 'a1' in this version segment mean under PEP 440?
It marks an alpha pre-release (the first alpha) of version 2.0.0 — an early, unstable preview that pip installs only if pre-releases are explicitly requested or no stable release satisfies the constraint.
Write a comment explaining why a pure-Python package's wheel filename ends in '-py3-none-any.whl' while a compiled-extension package's wheel filename includes something like '-cp312-cp312-manylinux_2_17_x86_64.whl'.
The wheel filename's tags encode Python version/implementation, ABI, and platform compatibility; pure-Python wheels can claim universal compatibility, compiled ones must be built per interpreter/platform combination.
mypkg-2.0.0a1-py3-none-any.whl