The Python Packaging Model: From Source Tree to Installed Package
Every page in this section - build backends, versioning, entry points, native extensions, publishing - is really describing one pipeline from different angles. A source tree becomes a distributable artifact through a standardized build step, that artifact carries metadata describing what it needs and provides, and an installer places it into an environment so import myapp works. Understanding that pipeline as one system, rather than a collection of separately memorized tools, is what makes the rest of this section's specifics stick.
This page lays out that pipeline: what a build backend actually does, why Python ships two distribution formats instead of one, what the tags on a wheel filename mean and why they exist, and where versioning, entry points, and native code fit into the same picture that Packaging Basics and Build Backends already introduce hands-on.
Summary
- Packaging is a pipeline - source tree, build backend, distribution artifact (sdist or wheel), installer - and every specific tool in this section is a piece of that one pipeline.
- Insight: Treating packaging as several unrelated tools (a build tool, a publishing tool, a versioning convention) hides why they have to agree with each other, and why a broken pipeline fails in confusing, non-local ways.
- Key Concepts: build backend, sdist, wheel, platform/ABI tag, PEP 517, entry point.
- When to Use: Deciding which backend to configure, understanding why a wheel built on Linux won't install on Windows, or diagnosing why an installed package is missing files.
- Limitations/Trade-offs: The standardization this pipeline provides (PEP 517/518) doesn't eliminate the underlying complexity of native code - it just gives that complexity a consistent interface.
- Related Topics: dependency resolution, virtual environments, native extension compilation, continuous integration for release artifacts.
Foundations
A Python package, in the distribution sense, starts as an ordinary source tree with a pyproject.toml at its root.
That file's [build-system] table names a build backend - hatchling, setuptools, poetry-core, maturin, and others - which is the piece of software actually responsible for turning that source tree into a distributable artifact.
This split matters because pip and uv don't know how to build your specific project; they know how to call whatever backend you've declared, following a standard interface, and let it do the actual work.
That standard interface is PEP 517: any backend that implements a small set of required functions (build a wheel, build an sdist) can be swapped in without the installer needing backend-specific logic, which is why moving from setuptools to hatchling is a config change, not a rewrite of how you install things.
There are two distribution artifacts a backend can produce, and they solve different problems.
An sdist (source distribution) is a snapshot of your source tree, plus enough metadata to build it later - it's portable across platforms because it hasn't been built yet.
A wheel is the opposite: a pre-built, ready-to-install artifact, laid out exactly as it should appear in site-packages, which is why installing from a wheel is fast - there's no build step at install time at all.
Mechanics & Interactions
The build backend is invoked, conceptually, at two different moments, and confusing them is a common source of packaging bugs.
Build time is when uv build or python -m build calls the backend to produce an sdist and/or wheel from your source tree; this is where [tool.hatch.build]-style configuration (what files to include, what to exclude, where packages live) actually takes effect.
Install time is when pip or uv takes an already-built wheel and copies its contents into an environment's site-packages, updating that environment's metadata so the package is importable; for a pure wheel install, the backend isn't involved at all at this stage.
An editable install (pip install -e . or uv pip install -e .) blurs this distinction deliberately - it registers your source tree as importable in place, without producing a real wheel, specifically so local development doesn't require a rebuild after every change.
Wheel filenames encode a contract that explains a common point of confusion: why a wheel built on one machine sometimes refuses to install on another.
myapp-1.2.0-cp313-cp313-manylinux_2_28_x86_64.whl
│ │ │ │
│ │ │ └─ platform tag: OS + arch this wheel targets
│ │ └─ ABI tag: binary interface it was compiled against
│ └─ Python tag: interpreter/version it targets (cp313 = CPython 3.13)
└─ package versionA pure-Python wheel typically carries py3-none-any in those slots, meaning it doesn't care about interpreter version, ABI, or platform - it's just Python source, so it installs anywhere.
A wheel containing compiled native code, by contrast, is only valid for the exact interpreter ABI and OS/architecture combination baked into those tags; that's not a packaging limitation so much as an honest description of what a compiled binary actually is.
Advanced Considerations & Applications
Everything this section covers beyond the core build/install loop is metadata or process layered on top of the same pipeline, not a separate system.
Versioning (Versioning & Changelogs) is metadata the build backend embeds into the artifact so installers and dependency resolvers can compare releases; SemVer is a convention for what version numbers should mean, not a mechanism the pipeline enforces on its own.
Entry points (Entry Points & Console Scripts) are metadata too - a mapping from a command name to a Python function - that the build backend writes into the wheel, and that the installer reads to generate an actual executable script at install time; the CLI you type in a terminal is generated, not hand-written.
Native extensions (Native Extensions & Wheels) add a compilation step before the same wheel-shaped output - the backend (often paired with a compiler toolchain, or a Rust-focused backend like maturin) produces .so/.pyd files that land inside the wheel just like pure-Python files would, tagged with the platform/ABI that compilation targeted. Building for multiple platforms means running that compilation step once per target, which is what tools like cibuildwheel automate rather than replace.
Publishing (Publishing to PyPI) is the final step of moving an already-built artifact to a registry that installers can find by name; nothing about publishing changes how the artifact was built, which is why testing a build locally before publishing (installing your own wheel and importing it) catches most packaging mistakes before they reach a registry.
Distributing applications (Distributing Applications) - PyInstaller, pipx, containers - largely exists because the wheel pipeline assumes a Python interpreter and environment already exist on the target machine; when that assumption doesn't hold (an end user with no Python installed), the pipeline gets wrapped in something that bundles an interpreter too.
| Distribution shape | What it assumes | What it solves | Cost |
|---|---|---|---|
| Sdist | A build backend and toolchain available at install time | Portability across platforms and Python versions | Slower install; requires a working build environment |
| Pure wheel | A compatible Python version only | Fast, build-free install | Doesn't cover compiled dependencies |
| Platform-specific wheel | An exact interpreter ABI + OS + architecture match | Fast install with native code included | Must be built separately per target platform |
| Bundled application (PyInstaller, container) | Nothing - no Python required on the target | Distribution to users without a Python environment | Much larger artifact; own update/patch story |
Reasoning about which row applies to a given release decision - "does this dependency have to be compiled per-platform" or "does my end user even have Python" - is the actual judgment call this whole section is building toward; the specific tool pages assume you can place a problem in this table before reaching for their commands.
Common Misconceptions
- "pip builds my package." pip orchestrates the process but delegates the actual build to whatever backend
[build-system]declares; pip doesn't know how to compile a Cython extension or lay out a wheel on its own. - "An sdist and a wheel are just different compression formats of the same thing." They differ in what stage of the pipeline they represent - an sdist is pre-build source, a wheel is a finished, ready-to-place artifact - and a wheel can contain files (compiled binaries) that never existed in the sdist's source form.
- "A wheel that works on my machine will work on any Linux box." Wheel platform tags encode real ABI and OS assumptions; a wheel built against one glibc version or CPU architecture can fail to import on a machine with a different one, which is exactly what
manylinux-style tags andcibuildwheelexist to manage systematically. - "Versioning is just picking a number." The version string is metadata other tools - dependency resolvers, changelogs, CI release gates - read and act on; an inconsistent or non-monotonic version can break resolution for every consumer of the package, not just cosmetics.
- "Native extensions are a completely separate packaging system." They add a compile step before the same wheel format everything else uses; the output still has to satisfy the identical platform/ABI tagging contract as a pure-Python wheel.
FAQs
What does a build backend actually do that pip doesn't?
It contains the project-specific logic for turning a source tree into a wheel or sdist - deciding what files belong, compiling any native code, and writing package metadata; pip only knows the standard interface (PEP 517) to call that logic, not how to perform it itself.
Why does Python have two distribution formats instead of one?
An sdist is a portable, pre-build snapshot useful when a wheel doesn't already exist for a platform; a wheel is a pre-built, install-ready artifact that skips the build step entirely - they trade portability for install speed in opposite directions.
What do the segments in a wheel filename mean?
They encode the package version, the Python interpreter tag, the ABI it was compiled against, and the platform/architecture it targets - a pure-Python wheel uses generic py3-none-any values, while a compiled wheel is pinned to the exact combination it was built for.
Why can't I just install any wheel I find on any machine?
Because a wheel with compiled native code is only valid for the interpreter ABI, OS, and architecture baked into its filename tags; installing it on a mismatched target either fails outright or, worse, imports successfully but crashes at runtime.
What's the difference between build time and install time?
Build time is when the backend turns source into an artifact and any project-specific build configuration takes effect; install time is when an already-built wheel's files are copied into an environment - for a pure wheel, the backend isn't involved at install time at all.
Why does an editable install not produce a real wheel?
Because its whole purpose is to skip the build step - it registers your source tree as importable in place so local changes are visible immediately without rebuilding, which is a development convenience rather than a distribution artifact.
Are entry points and console scripts a separate feature from packaging?
No - an entry point is metadata the backend writes into the wheel, and the installer reads that metadata to generate an actual executable script at install time, so the CLI command you run is generated from packaging metadata, not hand-authored.
Does adding a native extension change the overall packaging pipeline?
Not the pipeline's shape - it adds a compilation step before the backend produces the same wheel-shaped output, tagged with the platform/ABI the compiled code actually targets, which is why multi-platform native packages need to be built once per target.
Why would I need PyInstaller or a container instead of just a wheel?
A wheel assumes the target machine already has a compatible Python interpreter and environment; when an end user has no Python installed at all, that assumption fails, and the whole wheel pipeline gets wrapped inside something that bundles an interpreter too.
Is versioning enforced by the packaging pipeline itself?
The pipeline embeds whatever version string you provide as metadata and resolvers compare those strings, but the meaning of the number - SemVer conventions, pre-release ordering - is a convention teams follow, not something the build backend validates for correctness.
What actually breaks when a build backend and an sdist disagree about included files?
An sdist that's missing files the backend expects at build time will fail to rebuild on another machine, and a wheel that's missing files the backend expected to include produces a package that imports but is missing modules or data at runtime - both are failures of the "what belongs in this artifact" configuration, not of the installer.
Why test a locally built wheel before publishing it?
Because publishing only moves an already-built artifact to a registry - it doesn't validate the artifact's contents - so installing your own wheel into a fresh environment and importing it catches missing files or broken metadata before they reach every future consumer of the package.
Related
- Packaging Basics - the hands-on build/publish lifecycle
- Build Backends - choosing and configuring hatchling, setuptools, and alternatives
- Native Extensions & Wheels - the compile step before the wheel
- Versioning & Changelogs - the metadata riding on top of the artifact
- Entry Points & Console Scripts - generated CLI commands from packaging metadata
- Publishing to PyPI - moving a built artifact to a registry
Stack versions: This page was written for Python 3.14.0 (stable) / Python 3.13 (maintenance), with uv 0.6+ as the primary build/publish tool referenced.