A teammate asks “can I install this?” and suddenly your folder of .py files has nowhere to hide. Zip it? Email it? Tell them to clone the repo and run main.py? Python packaging exists so you never have that conversation: one pyproject.toml turns a pile of scripts into a package anyone can grab with a single pip install.
“Good packaging is invisible. Bad packaging is unforgettable.”
Dieter Rams
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 18 minutes
Think of packaging like boxing up furniture before a move. Loose chairs and a mattress thrown in a truck arrive scratched and missing screws. Flat-pack it with a parts list and instructions, and it shows up ready to assemble anywhere. A package is your code flat-packed: the source files, a parts list (dependencies), and an instruction sheet (metadata) that tells pip exactly how to put it back together on someone else’s machine.
Python packaging used to be a mess, and it earned that reputation honestly. There was setup.py, then setup.cfg, then MANIFEST.in, plus eggs and wheels and at least three tutorials that all disagreed. The modern answer is one file: pyproject.toml. It is a single, readable config that every build tool understands. Every new project you start in 2026 should use it. No exceptions.
A developer friend of mine, Aviraj, published his first library to PyPI (the Python Package Index) in about 30 minutes. He spent most of that time picking a name that was not already taken. The packaging part was the easy bit, and by the end of this post it will be easy for you too. We will build a tiny Command-Line Interface (CLI) tool, package it, build a real wheel, install it into a clean environment, and run it. Every command below was run on Python 3.14.6, and every output you see is the real thing.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram shows the standard layout of a packaged Python project. The pyproject.toml at the root holds the build config and metadata, a src/ directory holds your actual package code, and tests/ holds your tests. This is called the “src layout”. It keeps your package out of the way until it is properly installed, which quietly kills a whole class of “it works on my machine” import bugs. The pyproject.toml file replaces the old setup.py approach and is the standard for every new Python project. (The poetry.lock box only appears if you use Poetry, which we cover later.)
Table of Contents
Prerequisites
You should be comfortable with modules and packages (the difference between a single .py file and a folder with an __init__.py), and with virtual environments so you can install your package somewhere clean. Those three ideas carry all of Python packaging. Everything below was run on Python 3.14.6 on Windows. The commands are the same on macOS and Linux, just swap py -3.14 for python3.14.
Install and Verify the Build Tools
You need exactly two tools to build and ship a package the standard way: build (turns your source into distributable files) and twine (uploads those files to PyPI). Install both, then check they are alive.
📄 Terminal: install the build tools
py -3.14 -m pip install build twine py -3.14 -m build --version py -3.14 -m twine --version
▶ Output
build 1.5.0 (C:\tmp\py314-libs\Lib\site-packages\build) twine version 6.2.0 (keyring: 25.7.0, packaging: 26.2, requests: 2.34.2, requests-toolbelt: 1.0.0, urllib3: 2.7.0, id: 1.6.1)
What happened here: If you see version numbers instead of No module named build, you are ready. Your exact versions will differ from mine, and that is fine. Notice that build and twine are not part of your project. They are tools you install once into your environment, like a screwdriver you keep in a drawer, not a part you ship with the furniture. Your project itself stays clean.
The Quick Win: Package a CLI in 5 Minutes
Let us build something real and small: a command-line tool called greet that says hello. By the end of this section you will pip install it and run greet from anywhere, exactly like a tool you downloaded from PyPI. Create this folder structure.
📄 The greetcli project layout
greetcli/
pyproject.toml
README.md
src/
greetcli/
__init__.py
main.py
Here is the tiny CLI itself. It uses Click for argument parsing (a later post in the series covers Click in depth), so it counts as a real dependency we have to declare.
📄 src/greetcli/main.py
import click
@click.command()
@click.argument("name")
@click.option("--shout", is_flag=True, help="Greet in uppercase.")
def cli(name: str, shout: bool) -> None:
"""Greet NAME from the greetcli package."""
message = f"Hello, {name}! Welcome to Python packaging."
if shout:
message = message.upper()
click.echo(message)
if __name__ == "__main__":
cli()
And here is the whole point of the post, the pyproject.toml. Read it once now, top to bottom. We break down every line in the “Reading pyproject.toml” section below.
📄 pyproject.toml (setuptools backend)
[build-system]
requires = ["setuptools>=80.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "greetcli"
version = "1.0.0"
description = "A tiny CLI that greets people"
readme = "README.md"
license = "MIT"
requires-python = ">=3.11"
authors = [
{name = "Rahul Mahadik", email = "rahul@technoscripts.com"}
]
dependencies = [
"click>=8.0",
]
[project.optional-dependencies]
dev = ["pytest>=9.0", "ruff>=0.15"]
[project.scripts]
greet = "greetcli.main:cli"
[tool.setuptools.packages.find]
where = ["src"]
Now the payoff. From inside the greetcli/ folder, install your own project in “editable” mode (the -e flag) so Python links to your source directly, like a desktop shortcut that points at the original file instead of a copy. Then greet a couple of friends, say Anvi and Anvay.
📄 Terminal: install your own package and run it
py -3.14 -m pip install -e . greet Anvi greet Anvay --shout
▶ Output
Hello, Anvi! Welcome to Python packaging. HELLO, ANVAY! WELCOME TO PYTHON PACKAGING.
What happened here: That greet command did not exist on your system five minutes ago. It does now, because the [project.scripts] line told pip to create a launcher named greet that runs the cli function inside greetcli.main. This is exactly how tools like black, ruff, and pip itself become commands you can type. You just built one. Everything from here is understanding the pieces you already used.
Core Concept 1: The src Layout
Notice your code lives in src/greetcli/, not in a top-level greetcli/ next to pyproject.toml. That extra src/ folder looks pointless until it saves you. Here is the problem it prevents.
When you run Python from your project root, Python automatically adds that root to its import path. If your package sits right there in the root, your tests will import it directly from the working folder, never from the version you actually installed. So your tests pass, you publish, and users get a broken package because a file you forgot to include was always there on your disk. The src/ layout moves your code one level down where the project root cannot see it. Now the only way to import greetcli is to install it first, which means you test the real, installed thing.
Picture a restaurant. The src/ layout is like forcing every dish to go out through the pass and get plated, instead of letting you snack straight from the prep counter. You taste exactly what the customer tastes. The Python Packaging Authority (PyPA) recommends the src layout for this exact reason.
Core Concept 2: Reading pyproject.toml
The whole file is just two ideas in TOML format: who builds your package, and what your package is. Let us walk the sections.
[build-system] answers “who turns my source into an installable file?” Here we name setuptools as the build backend. Pip reads this section first, installs the listed tools into a private throwaway environment, and hands them your code. You almost never touch this section after writing it once.
[project] is the heart of the file: your package’s identity card. The name is what people type after pip install. The version is what they pin. The dependencies list is the parts list pip pulls in automatically, so installing greetcli also installs click. The requires-python line stops someone on an ancient Python from installing a package that would crash on them.
[project.optional-dependencies] holds extras that only some people need. Our dev group has pytest and ruff, the tools a contributor needs but a regular user does not. They install only when asked, with pip install greetcli[dev] once published, or pip install -e .[dev] while you are developing locally.
[project.scripts] is the line that created your greet command. The format is command-name = "module.path:function". Pip writes a small launcher so typing greet calls cli() inside greetcli/main.py.
license = "MIT", as you saw above. The old table form license = {text = "MIT"} still works but is deprecated under PEP 639 (a Python Enhancement Proposal). When you build with a recent setuptools, the SPDX string shows up as License-Expression: MIT in the package metadata. If you are reading an older tutorial that uses the {text = "..."} table, prefer the plain string for new projects.Build It: From Source to Wheel
Editable install is great while you develop. To share your package with the world, you build two real files: a wheel (.whl, the pre-built version pip installs fast) and a source distribution (.tar.gz, the raw source as a fallback). Think of a ready-to-eat veg thali versus a recipe card with raw ingredients: the wheel arrives cooked and pip just serves it, while the sdist makes the destination machine do the cooking. One command makes both, and it is the same command across the whole Python packaging ecosystem.
📄 Terminal: build the wheel and source distribution
py -3.14 -m build
▶ Output (trimmed)
* Creating isolated environment: venv+pip... * Installing packages in isolated environment: - setuptools>=80.0 - wheel * Getting build dependencies for sdist... * Building sdist... * Building wheel from sdist * Creating isolated environment: venv+pip... * Building wheel... Successfully built greetcli-1.0.0.tar.gz and greetcli-1.0.0-py3-none-any.whl
What happened here: build spun up a clean, isolated environment, installed setuptools into it, and asked setuptools to package your code twice: once as a source tarball, once as a wheel. Both files land in a new dist/ folder. The wheel name greetcli-1.0.0-py3-none-any.whl reads like a label: package greetcli, version 1.0.0, works on any Python 3 (py3), no C extensions (none), any platform (any). That last part is why pure-Python packages install instantly on every machine.
Before you ship anything, sanity-check the files with twine check. It catches broken metadata and README rendering problems that would otherwise show up as an ugly PyPI page.
📄 Terminal: validate the built files
py -3.14 -m twine check dist/*
▶ Output
Checking dist/greetcli-1.0.0-py3-none-any.whl: PASSED Checking dist/greetcli-1.0.0.tar.gz: PASSED
To prove the wheel really stands on its own, install it into a brand new virtual environment (one that has never seen your source code) and greet someone new, say a teammate named Aditi.
📄 Terminal: install the wheel into a clean environment
py -3.14 -m venv testenv testenv\Scripts\python -m pip install dist\greetcli-1.0.0-py3-none-any.whl testenv\Scripts\greet Aditi
▶ Output
Hello, Aditi! Welcome to Python packaging.
What happened here: The clean environment had no idea what greetcli was. Installing the wheel pulled in click automatically (your declared dependency), registered the greet launcher, and the command just worked. This is the real test of packaging: it runs somewhere your source code does not exist. If it works here, it will work for your users.
Publishing to PyPI
Once the wheel works in a clean environment, publishing is one command. The golden rule: always rehearse on TestPyPI first. It is a full clone of PyPI meant for exactly this, so you can make every rookie mistake somewhere that does not count.
📄 Terminal: upload to TestPyPI, then the real PyPI
# 1. Rehearse on TestPyPI (a safe sandbox) py -3.14 -m twine upload --repository testpypi dist/* # 2. When you are happy, upload to the real PyPI py -3.14 -m twine upload dist/*
greetcli 1.0.0 with different files, and you should not delete a release others may depend on. If you shipped a bug, you do not patch 1.0.0, you bump to 1.0.1 and upload that. Treat every version number as written in ink. Also, PyPI now requires an API (Application Programming Interface) token instead of your account password, and most projects use Trusted Publishing (an automated, token-free flow from CI, or Continuous Integration) for production releases. Check the current PyPI account docs when you set this up, since the auth steps change over time.The Poetry Alternative
Everything so far used setuptools plus build plus twine: three small tools, each doing one job, like a knife, a grater, and a whisk in a kitchen drawer. Poetry is the all-in-one food processor: dependency management, building, and publishing rolled into a single command-line tool. Many teams prefer it because one tool handles the whole life cycle and it writes a poetry.lock file that pins every exact version for perfectly reproducible installs.
📄 Terminal: the Poetry workflow
pip install poetry poetry new greetcli --src cd greetcli poetry add click poetry add --group dev pytest ruff poetry build # makes the wheel + sdist poetry publish # uploads to PyPI
Poetry uses the same pyproject.toml file, just with its own sections. Note that recent Poetry (2.x) can read the standard [project] table too, but plenty of existing projects still use the [tool.poetry] style below, so it is worth recognizing. (The author in this example, Viraj Patil, is just a placeholder person; put your own name there.)
📄 pyproject.toml (Poetry backend)
[tool.poetry] name = "greetcli" version = "1.0.0" description = "A tiny CLI that greets people" authors = ["Viraj Patil"] [tool.poetry.dependencies] python = "^3.11" click = "^8.0" [tool.poetry.group.dev.dependencies] pytest = "^9.0" ruff = "^0.15" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"
Poetry or setuptools? Reach for setuptools plus build plus twine when you want the simplest, most universal path that works everywhere with no extra tooling to learn. Reach for Poetry when you want one tool to manage dependencies, lock versions, and publish, especially on a team where reproducible installs matter. Neither is wrong. Both write a standard pyproject.toml, so you can switch later without rewriting your project.
The Ecosystem: build, twine, uv, Poetry
Python packaging is a small toolbox, and it helps to know which tool does what so the names stop blurring together.
- build turns your source into a wheel and an sdist. One job, done well. This is the official PyPA build front-end.
- twine uploads those files to PyPI and validates them with
twine check. It does nothing else, which is exactly why it is trusted. - setuptools is the build backend, the engine that actually assembles your package. You name it in
[build-system]and rarely think about it again. - Poetry bundles dependency management, building, and publishing into one tool with lock files.
- uv (version 0.11.23 at the time of writing) is the fast newcomer, a Rust-based tool that can also build packages with
uv build. It reads the same standardpyproject.toml, so trying it costs you nothing. We compare pip, Poetry, and uv head to head in the package managers tutorial.
The reassuring part: they all agree on pyproject.toml. Write that one file well and you can build with build, poetry build, or uv build, and the resulting wheel is identical. The config is the contract. The tools are interchangeable.
Common Mistakes
Mistake 1: Starting a new project with setup.py
🚫 Outdated: do not start new projects with this
# setup.py, the old imperative style from setuptools import setup setup(name="greetcli", version="1.0.0")
✅ Modern standard: declarative pyproject.toml
# pyproject.toml works with every build backend [project] name = "greetcli" version = "1.0.0"
Why: setup.py is executable code that runs at build time, which makes builds slower and harder to reason about. pyproject.toml is plain declarative config that any tool can read without running your code. The old distutils module that setup.py leaned on was removed from the standard library back in Python 3.12, so this style is genuinely on its way out. You will still see setup.py in older projects, and that is fine, but never reach for it on something new.
Mistake 2: Publishing straight to the real PyPI
🚫 Risky: first upload goes to production
py -3.14 -m twine upload dist/* # no rehearsal, no undo
✅ Safe: rehearse on TestPyPI first
py -3.14 -m twine upload --repository testpypi dist/*
Why: A version on PyPI is permanent. If your README renders wrong or you forgot a file, you cannot fix that exact version, you can only burn a version number and bump to the next one. TestPyPI lets you find those problems in a sandbox where nobody is watching.
Mistake 3: Forgetting the where = [“src”] line
If you use the src layout but leave out [tool.setuptools.packages.find] with where = ["src"], setuptools looks in the project root, finds nothing, and builds an empty package. The build succeeds, the wheel installs, and then import greetcli fails with ModuleNotFoundError. When a freshly installed package “has no code”, a missing where = ["src"] is the first thing to check.
Conclusion
You went from a folder of loose scripts to a real, installable package: a src/ layout that forces honest testing, a pyproject.toml that declares who builds it and what it needs, a wheel built with build, a clean-environment install that proved it works, and a safe TestPyPI-first path to publishing. You also saw the Poetry route, which manages the same file with one tool instead of three. That is the entire modern Python packaging workflow, and it fits in one config file.
Next we take that packaged app and containerize it so it runs the same anywhere, with Docker, Compose, and multi-stage builds. For the full learning path from beginner to AI/ML, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is pyproject.toml in Python?
It is the modern standard config file for Python projects, defined by PEP 518 and PEP 621. A single TOML file holds your build system, project metadata, and dependencies, replacing the old setup.py, setup.cfg, and MANIFEST.in. Every new project should use a Python pyproject.toml.
Should I use Poetry or setuptools?
Use setuptools with build and twine for the simplest, most universal path that needs no extra tooling. Use Poetry if you want one tool for dependency management, lock files, building, and publishing, which suits teams that care about reproducible installs. Both write a standard pyproject.toml, so you can switch later without redoing your Python packaging setup.
What is a wheel (.whl) file?
A wheel is Python’s pre-built distribution format. Unlike a source distribution (.tar.gz), a wheel is ready to install, so pip just unpacks it without running any build step. That makes installs faster and more reliable. The build tool creates both formats, and you publish both.
How do I make a CLI tool installable with pip?
Add a [project.scripts] section in pyproject.toml that maps a command name to a function, for example greet = “greetcli.main:cli”. After pip install, pip creates a launcher so users can run greet from any terminal, the same way black and ruff work.
Why use a src layout instead of putting code in the project root?
The src layout moves your package one level down so the project root cannot import it by accident. That forces your tests to import the installed package, not the loose files on disk, which catches missing-file bugs before your users do. The Python Packaging Authority recommends it.
Is setup.py dead in 2026?
For new projects, yes, prefer pyproject.toml. The distutils module that setup.py relied on was removed from the standard library in Python 3.12. You will still meet setup.py in older codebases and it still works, but do not start anything new with it.
Try It Yourself
Take one of your earlier projects (the calculator from the Tkinter GUI tutorial, or any small utility script) and package it. Write a pyproject.toml with proper metadata, move the code into a src/ layout, and add a [project.scripts] entry point so it becomes a real command. Then run py -3.14 -m build, create a fresh virtual environment, install the wheel from dist/ with pip install dist\*.whl, and run your new command. If it works in the clean environment, you have shipped a real package.
Interview Questions on Python Packaging
How interviewers actually probe this topic: real scenarios, with answers you can say out loud.
Q: Your wheel builds without errors and installs cleanly into a fresh virtual environment, but import greetcli raises ModuleNotFoundError. What do you check first?
The wheel is almost certainly empty. With a src layout and the setuptools backend, a missing [tool.setuptools.packages.find] with where = ["src"] makes setuptools search the project root, find no packages, and happily build a wheel with metadata but no code. Confirm it by listing the wheel contents (a .whl is just a zip, so py -3.14 -m zipfile -l dist\greetcli-1.0.0-py3-none-any.whl works) and checking whether your package directory is inside. Also verify the package folder has an __init__.py.
Q: You tested your app against one set of dependency versions, but a teammate’s install pulls newer versions and breaks in production. How do you make installs reproducible?
The dependencies list in pyproject.toml is intentionally a range (like click>=8.0), so every install resolves it fresh. For an application you add a lock file that pins exact versions of the whole dependency tree: poetry.lock with Poetry, uv.lock with uv, or a pinned requirements/constraints file generated with pip-tools or pip freeze. The rule of thumb: libraries declare loose ranges so they compose with other packages, applications lock exact versions so every machine installs the same thing.
Q: What is the difference between a build front-end and a build backend?
The front-end is what you run: build, pip, or uv build. It reads [build-system], creates an isolated environment with the listed requirements, and calls the standard PEP 517 hooks. The backend is what actually assembles the wheel and sdist: setuptools, poetry-core, hatchling, or flit-core. Because the hooks are standardized, any front-end can drive any backend, which is why one pyproject.toml works across all these tools.
Q: The wheel we built is named greetcli-1.0.0-py3-none-any.whl. What do the last three parts mean, and how would the name change if the package included C extensions?
They are compatibility tags: py3 is the Python tag (any Python 3 implementation), none is the ABI tag (no compiled binary interface), and any is the platform tag (any operating system). A package with C extensions gets tags like cp314-cp314-win_amd64, meaning it only works on CPython 3.14.6 on 64-bit Windows, so the maintainer must publish a separate wheel per platform and Python version. That is why pure-Python packages ship one universal wheel while NumPy publishes dozens per release.
Q: You published version 1.0.0 to PyPI and found a critical bug ten minutes later. Can you fix the files and re-upload?
No. PyPI filenames are immutable: once greetcli-1.0.0 exists, those exact files can never be replaced, even if you delete the release. The correct move is to yank 1.0.0 (pip then skips it for new installs unless someone pinned ==1.0.0 explicitly), fix the bug, and publish 1.0.1. This immutability is a supply-chain security feature, not an inconvenience: it guarantees the same version always means the same bytes.
Q: Why does the build tool create an isolated environment instead of using the one you already have active?
To guarantee the build only depends on what [build-system] declares. If it built inside your everyday environment, a tool you happened to have installed could silently make the build work on your machine and fail on CI or a user’s machine. The throwaway environment installs exactly the declared requirements (here setuptools and wheel), builds, and is discarded, so a passing build proves the config is complete. You can skip it with --no-isolation for speed, but only when you know the trade-off.
Reference: the complete, always-current details live in the official Python documentation.
Related Posts
Previous: Python: GUI Programming with Tkinter (Windows, Widgets, Events)
Next: Python: Dockerizing Python Apps with Dockerfile, Compose, and Multi-Stage Builds
Series Home: Python + AI/ML Tutorial Series

No comment