Packaging & Publishing to PyPI
Reviewed & published by Brayan K
Learn how to turn your Python code into a real, installable package that anyone can use with pip install.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
📦 What You'll Learn
This lesson teaches you how to publish professional Python packages:
- Structuring a library properly
- Creating pyproject.toml configuration
- Building wheels and source distributions
- Uploading to TestPyPI and PyPI with twine
- Versioning and metadata best practices
- Making your package discoverable and trustworthy
After this lesson, anyone can install your code with:
📥 Python Download & Setup
Download Python from: python.org/downloads
Latest version recommended (3.11+)
Part 1: Package Structure & Building
1. What Is a Python Package?
Think of a package like a product in a box. The box (folder) contains everything needed: the product itself (your code), instructions (README), warranty info (LICENSE), and a label with specs (pyproject.toml). Anyone can "buy" your product with pip install!
A package is just a directory with an __init__.py file that can be imported.
my_cool_lib/
├─ src/
│ └─ my_cool_lib/
│ ├─ __init__.py
│ ├─ core.py
│ └─ utils.py
├─ tests/
├─ pyproject.toml
├─ README.md
├─ LICENSE
└─ .gitignore| File/Folder | Purpose | Required? |
|---|---|---|
| src/my_cool_lib/ | Your actual library code | ✅ Yes |
| pyproject.toml | Build config & metadata | ✅ Yes |
| README.md | Documentation (shown on PyPI) | ✅ Highly recommended |
| LICENSE | Usage permissions | ✅ Highly recommended |
| tests/ | Unit tests | Optional but best practice |
The src/ layout is recommended because it catches import mistakes early.
2. Basic Code Layout
__all__ = ["say_hello", "__version__"]
from .core import say_hello
__version__ = "0.1.0"def say_hello(name: str) -> str:
return f"Hello, {name}!"Users will be able to do:
from my_cool_lib import say_hello
print(say_hello("World"))🧑🏫 Worked example: the whole library in one runnable file
A package spread across four files is hard to picture until you have built one. So here is the same library squashed into a single file you can actually run. Each block is labelled with the file it belongs in once you split it up, and the comments explain the conventions that make a package look professional: a single __version__, an __all__ list that declares the public API, an underscore prefix for private helpers, and a main() that a console script points at.
# WORKED EXAMPLE: the whole library, in one runnable file.
# When you publish it, each labelled block moves into the file named above it.
# Everything here runs as-is, so you can try the API before you ship it.
# ===== src/my_cool_lib/core.py =====
def say_hello(name: str) -> str:
"""Return a greeting. This docstring is what help() and your docs site show."""
if not name: # validate at the public edge, not deep inside
raise ValueError("name must not be empty")
return f"Hello, {name}!"
def _shout(text: str) -> str:
"""Private helper. A leading underscore means 'not part of the public API'."""
return text.upper()
# ===== src/my_cool_lib/__init__.py =====
__version__ = "0.1.0" # one source of truth for the version number
__all__ = ["say_hello"] # what 'from my_cool_lib import *' hands over
# ===== src/my_cool_lib/cli.py =====
def main() -> int:
"""Console-script entry point.
pyproject.toml maps a command name onto this function:
[project.scripts]
cool-hello = "my_cool_lib.cli:main"
Return 0 for success; any other number means failure to the shell.
"""
print(say_hello("command line"))
return 0
# ===== what somebody using your library actually sees =====
print("version:", __version__)
print(say_hello("World"))
print("public API:", __all__)
print("private helper is still importable, just not advertised:", _shout("psst"))
try:
say_hello("") # your validation doing its job
except ValueError as exc:
print("ValueError:", exc)
print("exit code from main():", main())
# ✅ Output:
# version: 0.1.0
# Hello, World!
# public API: ['say_hello']
# private helper is still importable, just not advertised: PSST
# ValueError: name must not be empty
# Hello, command line!
# exit code from main(): 0
#
# Note the last two lines: main() runs first (printing its greeting), and only then
# does the outer print show its return value. __all__ does not make _shout private -
# nothing in Python is truly private - it only decides what 'import *' brings in.
# ✅ Expected output:
# version: 0.1.0
# Hello, World!
# public API: ['say_hello']
# private helper is still importable, just not advertised: PSST
# ValueError: name must not be empty
# Hello, command line!
# exit code from main(): 03. The Modern Way: pyproject.toml
pyproject.toml is now the standard way to describe:
- Project metadata (name, version, description)
- Build backend (like setuptools)
- Dependencies
Minimal example using setuptools:
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my-cool-lib"
version = "0.1.0"
description = "A tiny example Python library."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [
{ name = "Jane Developer", email = "[email protected]" }
]
dependencies = [
"requests>=2.31.0",
]
[project.urls]
"Homepage" = "https://github.com/yourname/my-cool-lib"
"Bug Tracker" = "https://github.com/yourname/my-cool-lib/issues"
[tool.setuptools.packages.find]
where = ["src"]Key points:
- name → must be unique on PyPI
- version → follows semantic versioning (e.g. 0.1.0)
- requires-python → minimum Python version you support
- dependencies → packages installed when someone installs your library
4. Semantic Versioning (How to Version Properly)
Think of versions like software updates on your phone. A PATCH (15.0.1 → 15.0.2) fixes bugs quietly. A MINOR update (15.1) adds features. A MAJOR update (15 → 16) might change everything and require you to relearn things — that's a breaking change.
| Part | When to Bump | Example |
|---|---|---|
| PATCH | Bug fixes, no breaking changes | 0.1.0 → 0.1.1 |
| MINOR | New features, backwards compatible | 0.1.1 → 0.2.0 |
| MAJOR | Breaking changes | 0.2.0 → 1.0.0 |
Store the version in one place (e.g. __init__.py) and keep pyproject.toml in sync or use a tool like setuptools-scm later.
🎯 Your turn: work out the next version
Version numbers are a promise to your users, so getting the bump right matters more than it looks. The program below is written apart from three ___ blanks. Fill them in and run it, then change change to "breaking" and run it again — watch what happens to the last line.
# 🎯 YOUR TURN - work out the next version number
# Replace each ___ below, then press Run.
current = "1.4.2"
major, minor, patch = (int(part) for part in current.split("."))
# Semantic versioning is MAJOR.MINOR.PATCH:
# breaking change -> bump MAJOR, and everything to its right resets
# new feature -> bump MINOR, and everything to its right resets
# bug fix -> bump PATCH
change = "feature" # later, try "breaking" and "fix"
if change == "breaking":
major, minor, patch = major + 1, 0, 0
elif change == "feature":
major, minor, patch = major, minor + 1, ___ # 👉 replace ___ with what PATCH resets to
else:
major, minor, patch = major, minor, ___ # 👉 replace ___ so PATCH goes up by one
new_version = f"{major}.{minor}.{patch}"
print(f"{current} + {change} -> {new_version}")
# Your users pinned "my-cool-lib>=1.4,<2.0" in their own requirements file.
# They will pick this release up automatically only while MAJOR stays below 2.
safe_for_users = major ___ 2 # 👉 replace ___ with the comparison operator
print("existing users on >=1.4,<2.0 get it automatically:", safe_for_users)
# ✅ Expected output once the blanks are filled in:
# 1.4.2 + feature -> 1.5.0
# existing users on >=1.4,<2.0 get it automatically: True
#
# Now set change = "breaking". You should get 2.0.0, and the second line should
# flip to False - that is exactly what a major version bump means to your users.5. Writing a Good README for PyPI
Your README.md becomes the landing page on PyPI.
- Short description
- Installation instructions
- Basic usage example
- Features list
- Links (docs, repo, issues)
# my-cool-lib
A small Python library that says hello.
## Installation
```bash
pip install my-cool-lib
```
## Quick Start
```python
from my_cool_lib import say_hello
print(say_hello("World")) # → "Hello, World!"
```
## Features
- Simple API
- Type hints
- Tested with pytestMake sure readme = "README.md" is set correctly in pyproject.toml.
6. Building the Distribution (Wheel + sdist)
Building a package is like baking bread. sdist is like shipping the recipe and ingredients — the customer bakes it themselves. Wheel is like shipping the finished loaf — ready to eat immediately. Most people prefer the wheel (faster to install)!
pip install buildThen from your project root (same folder as pyproject.toml):
python -m builddist/
├─ my_cool_lib-0.1.0.tar.gz # Source distribution (sdist)
└─ my_cool_lib-0.1.0-py3-none-any.whl # Wheel (built package)| Type | File Extension | Install Speed | When Used |
|---|---|---|---|
| Wheel | .whl | ⚡ Fast | Default installation |
| sdist | .tar.gz | 🐢 Slower | Fallback, building from source |
7. Uploading to TestPyPI (Safe Practice Run)
Before using the real PyPI, publish to TestPyPI.
pip install twinetwine upload --repository-url https://test.pypi.org/legacy/ dist/*You'll be asked for:
- username: often __token__ if using API tokens
- password: your TestPyPI API token
Then test install it in a fresh venv:
python -m venv test-env
source test-env/bin/activate # or .\test-env\Scripts\activate on Windows
pip install -i https://test.pypi.org/simple/ my-cool-libfrom my_cool_lib import say_hello
print(say_hello("World"))If everything looks good, you're ready for real PyPI.
8. Publishing to the Real PyPI
- Create an account on pypi.org
- Generate an API token
- Configure ~/.pypirc (optional but helpful):
[distutils]
index-servers =
pypi
[pypi]
username = __token__
password = pypi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxtwine upload dist/*After a successful upload, users can:
pip install my-cool-libAnd import it like any other library.
9. Updating Your Package (New Versions)
When you change the code:
- Bump the version (e.g. 0.1.0 → 0.1.1) in: pyproject.toml
- __init__.py (if you mirror the version there)
- Rebuild: python -m build
- Upload again: twine upload dist/*
PyPI will reject duplicate versions, so you must use a new version number each time.
10. Minimal Checklist Before Publishing
- Package name is unique on PyPI
- Code is inside src/your_package_name/
- __init__.py defines __version__
- pyproject.toml is valid and has metadata
- README.md exists and readme is set in pyproject.toml
- Build succeeds (python -m build)
- TestPyPI upload + install works
- Version is bumped for each release
🎯 Mini Challenge: automate that checklist
A checklist you tick by hand is a checklist you eventually skip. Turn the four most important items into code that runs before every upload. This one is faded — an outline only, no logic written for you. Stick to the exact printed text in the comments and your output will match line for line.
# 🎯 MINI-CHALLENGE: a pre-flight check to run before "twine upload"
project = {
"name": "My_Cool_Lib",
"version": "0.1",
"readme": True,
"license": False,
}
# 1. Start a counter: failures = 0
#
# 2. Check the name. It passes when the name is already all lowercase
# AND contains no underscore. Print one of:
# "PASS name"
# "FAIL name - use lowercase with hyphens, not underscores or capitals"
#
# 3. Check the version. It passes when splitting it on "." gives exactly 3 parts.
# "PASS version"
# "FAIL version - use three parts: MAJOR.MINOR.PATCH"
#
# 4. Check readme is True.
# "PASS readme"
# "FAIL readme - PyPI shows a blank page without one"
#
# 5. Check license is True.
# "PASS license"
# "FAIL license - nobody can legally use your code without one"
#
# 6. Add 1 to failures on every FAIL, then finish with either
# "Ready to publish." (when failures is 0)
# f"{failures} problem(s) - fix before uploading."
# your code here
# ✅ Expected output:
# FAIL name - use lowercase with hyphens, not underscores or capitals
# FAIL version - use three parts: MAJOR.MINOR.PATCH
# PASS readme
# FAIL license - nobody can legally use your code without one
# 3 problem(s) - fix before uploading.
#
# Then fix the project dictionary ("my-cool-lib", "0.1.0", license True) and
# run it again - you should get four PASS lines and "Ready to publish."Part 2: Advanced Package Features
11. Adding Optional Features With "Extras"
Sometimes you want optional dependencies that users can install only if they need them.
Example: a core library with an optional cli or dev feature:
[project.optional-dependencies]
cli = [
"typer>=0.12.0",
"rich>=13.0.0",
]
dev = [
"pytest>=8.0.0",
"mypy>=1.10.0",
"ruff>=0.6.0",
]Users can then install:
pip install my-cool-lib[cli]
pip install my-cool-lib[dev]This keeps the base install lightweight, while still offering powerful extras for people who want them.
12. Entry Points & Console Scripts (Installing a CLI)
You can ship a command-line tool with your package so users get a global command after installing.
[project.scripts]
mycool = "my_cool_lib.cli:main"# src/my_cool_lib/cli.py
def main() -> None:
print("Hello from mycool CLI!")pip install my-cool-lib
mycool
# → Hello from mycool CLI!This is how black, pytest, pip, etc. expose commands.
13. Classifiers: Telling PyPI & Tools What Your Package Supports
- Supported Python versions
- Intended audience
- Topic (e.g. web, ML, data)
[project]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
]- Discoverability on PyPI
- Confidence for users (they can see support clearly)
- Filtering in tools / searches
14. Handling Dependencies Safely
Some tips for dependencies:
1. Pin or semi-pin versions in development, but keep install requirements slightly relaxed.
[project]
dependencies = [
"requests>=2.31,<3.0",
]In your own dev environment you can use a lock file from tools like pip-tools, Poetry, or uv.
2. Avoid unnecessary dependencies
- Easier to install
- Less likely to break
15. Testing Before Publishing
Before uploading to PyPI, always run tests in a clean environment.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]" # editable install + dev extras
pytest- -e → editable install (imports code directly from src/)
- .[dev] → includes dev dependencies from optional-dependencies
If tests pass in a clean venv, chances are much higher that users won't hit import errors.
16. Testing Installation Like a Real User
After building and uploading to TestPyPI, test your package the way a real user would:
- Create a fresh venv:
python -m venv test-env
source test-env/bin/activate- Install from TestPyPI:
pip install -i https://test.pypi.org/simple/ my-cool-lib- Open Python and try:
If that works, your packaging, imports, and metadata are all aligned correctly.
a) Forgetting __init__.py
If __init__.py is missing, Python won't treat the directory as a package.
src/
my_cool_lib/
__init__.py ← must exist
core.pyb) Wrong where for packages
If you use the src/ layout, you must tell setuptools:
[tool.setuptools.packages.find]
where = ["src"]Otherwise your built wheel may contain no code, and imports will fail.
c) Importing the package from the project root during dev
- Use pip install -e . and run scripts via python -m my_cool_lib.something, or
- Put your scripts under src/ and run through modules
d) Uploading the same version twice
PyPI will reject re-uploads of a version. If you made a mistake:
- Bump the version (e.g. 0.1.0 → 0.1.1)
- Rebuild with python -m build
- Upload again with twine
18. Keeping Secrets Safe (Do Not Hardcode Keys)
Never put secrets (tokens, passwords) in your package.
API_KEY = "sk-123456"- Use environment variables (os.environ["MY_API_KEY"])
- Or read from config files that are not in the published package
- Or let users pass keys into your functions/classes
Anything inside src/ will be visible to anyone on PyPI or GitHub, so treat it as public.
19. Automating Publishing With CI (Overview)
Later on, you can automate releases using CI like GitHub Actions.
- Tag a release (e.g. v0.2.0)
- CI workflow runs: installs dependencies
- builds package (python -m build)
- uploads with twine using a PyPI token stored as a secret
Very rough example .github/workflows/release.yml structure:
After this, releasing is as simple as pushing a tag.
20. Making Your Package Friendly for Contributors
A polished package is easier for others to use and contribute to.
- Include a CONTRIBUTING.md explaining: how to set up dev environment
- how to run tests
- coding style or lint rules
- Add a simple Makefile or tasks.py with shortcuts
- Use a linter/formatter (ruff, black, isort) and mention them in dev extras
21. Summary of the Packaging Workflow
- Create Structure Use src/your_package/ layout. Add __init__.py, modules, tests
- Describe Project Create pyproject.toml with metadata, dependencies, optional extras, scripts
- Write Code + Tests Implement core logic. Add unit tests with pytest
- Build python -m build
- Test on TestPyPI Upload with twine to TestPyPI. Install in a clean venv. Import and run sample code
- Publish to PyPI Configure PyPI token. twine upload dist/*
- Repeat with New Versions Bump version for each change. Rebuild and re-upload
Part 3: Professional Polish & Best Practices
22. Handling Versioning Properly (Semantic Versioning Made Simple)
PyPI expects clear, predictable versioning. The best standard is SemVer:
- MAJOR → breaking changes
- MINOR → new features, no breaks
- PATCH → bug fixes
- 1.0.0 → first stable release
- 1.1.0 → added new features
- 1.1.5 → bug fixes only
23. Cross-Platform Compatibility
Make your package work on Windows, macOS, and Linux.
Use pathlib for paths:
from pathlib import Path
config = Path.home() / ".mycoolconfig"24. Adding Type Hints for Better DX (Developer Experience)
Typed packages are dramatically easier to use.
- Inline type hints
- Or a separate py.typed file to mark package typing support
And declare in pyproject.toml:
[tool.setuptools.package-data]
"my_cool_lib" = ["py.typed"]This lets editors like VSCode, PyCharm & MyPy give:
- Autocomplete
- Type checking
- Safer refactors
Typed libraries are considered higher-quality on PyPI.
25. Adding Documentation (README, Examples, Tutorials)
A strong PyPI project must have:
- A clear README
- Usage examples
- API documentation
- Badges (version, downloads)
- Feature list
- Contribution guide
PyPI will display this directly on the project page.
26. Adding a License (Important for Public Use)
If you publish to PyPI without a license, companies legally cannot use your code.
- MIT License (very permissive)
- Apache 2.0 (enterprise-friendly)
- GPL (viral, requires open sourcing forks)
Add a LICENSE file at project root and reference it in pyproject.toml:
[project]
license = { file = "LICENSE" }Open-source developers expect this.
27. Keeping Your Package Secure
PyPI packages must remain safe. Follow these guidelines:
- OAuth tokens
- Database passwords
- Embedded credentials
- Using eval()
- Arbitrary imports based on user input
- Downloading code from the internet
pip install bandit
bandit -r src/
pip install safety
safety checkSecurity matters for your reputation and your package's adoption.
28. Distributing Both sdist and Wheel (Why It Matters)
When you run python -m build, you get two types of packages:
- mycoollib-1.0.0.tar.gz → source distribution
- mycoollib-1.0.0-py3-none-any.whl → wheel
- sdist = source code (for platforms that build from scratch)
- wheel = pre-built, faster installs
Wheels install instantly and are preferred on modern Python.
29. Supporting Multiple Python Versions
You choose which Python versions your package supports.
[project]
requires-python = ">=3.9"to test on multiple interpreters. This makes your package more stable and predictable for users.
30. Writing a Professional __init__.py
Your package's public API should be explicitly controlled.
from .core import greet, add
from .utils import logger
__all__ = ["greet", "add", "logger"]- Clear public interface
- Cleaner imports for users
- No accidental API exposure
Good packages have well-designed import paths.
31. Packaging Non-Python Files (Assets, Templates, etc.)
- Config templates
- JSON/YAML files
- Static resources
- HTML templates
[tool.setuptools.package-data]
"my_cool_lib" = ["templates/*.html", "data/*.json"]from importlib.resources import files
template = files("my_cool_lib").joinpath("templates/index.html").read_text()This is cleaner than bundling file paths manually.
32. Popular Tools That Improve the Publishing Workflow
- black — automatic formatting
- ruff — ultra-fast linting
- mypy — typing enforcement
- pre-commit — hooks to auto-format before commit
- uv/Pipenv/Poetry — dependency managers
These make your package "production-grade."
33. Final Practical Checklist Before Publishing
Here is the final checklist used by professional Python maintainers:
- ✓ Project structure correct
- ✓ pyproject.toml complete
- ✓ Version bumped
- ✓ Tests pass
- ✓ README is clear
- ✓ LICENSE added
- ✓ No secrets
- ✓ Build successful
- ✓ TestPyPI upload & install verified
- ✓ Publish to PyPI
If all boxes are checked → ship it.
🎓 Final Summary
You've now mastered professional Python package publishing to PyPI.
- Structure a library with src/ layout
- Configure pyproject.toml with all metadata
- Build wheels and source distributions
- Test on TestPyPI before publishing
- Publish to PyPI with twine
- Version properly with semantic versioning
- Add optional extras and CLI tools
- Keep packages secure and cross-platform
- Automate releases with CI/CD
These skills let you publish professional Python libraries used by developers worldwide — just like requests, FastAPI, and thousands of other packages on PyPI.
📋 Quick Reference — Packaging & PyPI
| Tool / File | What it does |
|---|---|
| pyproject.toml | Modern project metadata and build config |
| python -m build | Build wheel and sdist |
| twine upload dist/* | Upload to PyPI |
| twine check dist/* | Validate package before upload |
| pip install -e . | Install package in editable/dev mode |
🎉 Great work! You've completed this lesson.
You can now package your Python code and publish it to PyPI — making your work installable by anyone in the world with pip.
Practice quiz
Which file is the modern standard for declaring a package's metadata and build config?
- setup.cfg
- requirements.txt
- pyproject.toml
- MANIFEST.in
Answer: pyproject.toml. pyproject.toml is the modern standard describing metadata, build backend, and dependencies.
What command builds both a wheel and a source distribution (sdist)?
- python -m build
- pip install .
- twine upload dist/*
- python setup.py register
Answer: python -m build. python -m build produces the .whl (wheel) and .tar.gz (sdist) in the dist/ folder.
What is the difference between a wheel and an sdist?
- A wheel is source code; an sdist is pre-built
- They are identical formats
- A wheel can't be uploaded to PyPI
- A wheel is a pre-built package (faster install); an sdist ships source to build from
Answer: A wheel is a pre-built package (faster install); an sdist ships source to build from. Wheels (.whl) install fast as pre-built; sdists (.tar.gz) contain source built on the user's machine.
Which tool uploads your built distributions to PyPI?
- pip
- twine
- build
- setuptools
Answer: twine. twine upload dist/* publishes your wheel and sdist to (Test)PyPI.
In MAJOR.MINOR.PATCH semantic versioning, which part bumps for a breaking change?
- MAJOR
- PATCH
- MINOR
- None of them
Answer: MAJOR. Breaking changes bump MAJOR; MINOR adds backward-compatible features; PATCH is bug fixes.
Why is publishing to TestPyPI recommended before the real PyPI?
- It is required by law
- TestPyPI packages auto-publish to PyPI
- It's a safe practice run to verify upload and install without affecting the real index
- It makes the package free
Answer: It's a safe practice run to verify upload and install without affecting the real index. TestPyPI lets you rehearse uploading and installing safely before the real release.
What happens if you try to upload a version that already exists on PyPI?
- It silently overwrites the old one
- PyPI rejects the duplicate; you must bump to a new version number
- It merges the two uploads
- It deletes the package
Answer: PyPI rejects the duplicate; you must bump to a new version number. PyPI forbids re-uploading the same version, so each release needs a new version number.
With the src/ layout, what tells setuptools where to find your package?
- [project] src = true
- Nothing; it is automatic
- A MANIFEST.in entry
- [tool.setuptools.packages.find] where = ["src"]
Answer: [tool.setuptools.packages.find] where = ["src"]. You must point package discovery at src via [tool.setuptools.packages.find] where = ["src"].
How do users install an optional 'extras' group, e.g. a cli extra?
- pip install my-cool-lib --cli
- pip install my-cool-lib[cli]
- pip install my-cool-lib.cli
- pip install cli-my-cool-lib
Answer: pip install my-cool-lib[cli]. Extras declared under [project.optional-dependencies] are installed with the bracket syntax pkg[extra].
Which pyproject.toml section ships a console command like 'mycool'?
- [project.dependencies]
- [build-system]
- [project.scripts]
- [project.classifiers]
Answer: [project.scripts]. [project.scripts] maps a command name to a function entry point (e.g. my_cool_lib.cli:main).
Continue this course
- Previous: Virtual Environments & Dependency Management Best Practices
- Next: Working with Files, Streams & Large Datasets — Handle large files with streaming, chunking, and pathlib
- Quick reference: Python cheat sheet