Package Python Apps and Publish to PyPI
Learn to package Python apps and publish to PyPI with hands-on exercises, troubleshooting tips, and next steps.
Focus: package python apps and publish to pypi
So you've built a Python app that actually works — now you want to share it with the world? Manually zipping files or copying code isn't sustainable, and nobody wants to install from a random GitHub repo. This is exactly where packaging your Python app and publishing it to PyPI (Python Package Index) comes in — the official, standardized way to distribute Python software. By the end of this lesson, you'll know how to take your project from a local folder to a pip install-able package on PyPI.
The problem this lesson solves
Distributing Python software has historically been a pain. Think about the last time you needed to share a script with a teammate: you probably zipped a folder, emailed an archive, or pointed them to a Git repo. None of those solutions scale. Users expect a simple, one-line command — pip install yourpackage — to get your code, along with all its dependencies. Without proper packaging, you're stuck managing manual installs, broken imports, and version conflicts.
This lesson tears down that wall. After you've done the work, anyone in the world can install, upgrade, and uninstall your Python app with standard tools.
Core concept / mental model
Think of PyPI as the App Store for Python. Just as you'd submit an iPhone app to the App Store so users can download it with one click, you upload your Python package to PyPI so users can pip install it. The package itself is more than just your code — it's a portable, self-describing collection that includes:
- Your source files (the actual Python modules)
- Metadata (name, version, author, description)
- Dependencies (other packages your code needs)
- Build instructions (how to assemble the package, often with
setuptools)
Pro tip: A Python package is really just a directory with an
__init__.pyfile. Thesetup.pyorpyproject.tomlfile is the recipe card that tells pip how to install it.
How it works step by step
1. Structure your project
Every distributable Python package follows a standard structure. Here's a minimal example:
my_package/
├── my_package/
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
├── pyproject.toml
├── README.md
├── LICENSE
└── .gitignore
Your actual code lives in a subdirectory named after your package (e.g., my_package/). The __init__.py file makes Python treat the directory as a package.
2. Write the build configuration
Modern Python packaging uses pyproject.toml (PEP 621) or setup.py. I strongly recommend pyproject.toml — it's the community standard as of Python 3.10+.
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "0.1.0"
authors = [
{name="Your Name", email="you@example.com"}
]
description = "A short description of your package"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"requests>=2.31.0",
]
[project.urls]
Homepage = "https://github.com/yourname/my-package"
Repository = "https://github.com/yourname/my-package"
Notice the dependencies list — pip will automatically install requests when someone runs pip install my-package.
3. Build your package
With the configuration in place, install build and create distribution files:
pip install build
ython -m build
This produces two files in dist/:
- A source distribution (.tar.gz)
- A wheel (.whl) — the faster, preferred format
4. Upload to PyPI
First, create an account on pypi.org and generate an API token. Then install twine:
pip install twine
ython -m twine upload dist/*
Twine will prompt for your username (use __token__) and password (your API token). After a successful upload, your package is live!
Hands-on walkthrough
Let's create a real package from scratch. We'll make a simple utility that greets users.
Step 1: Create the project structure
mkdir greeting-utils
cd greeting-utils
mkdir greeting_utils
ytouch greeting_utils/__init__.py
ytouch greeting_utils/greet.py
Step 2: Write the code
In greeting_utils/greet.py:
def say_hello(name: str) -> str:
"""Return a friendly greeting."""
return f"Hello, {name}! Welcome to PyPI."
def say_goodbye(name: str) -> str:
"""Return a farewell message."""
return f"Goodbye, {name}. See you next time!"
In __init__.py:
from .greet import say_hello, say_goodbye
Step 3: Add pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "greeting-utils"
version = "0.1.0"
description = "A simple greeting utility"
readme = "README.md"
requires-python = ">=3.10"
[project.urls]
Homepage = "https://github.com/yourname/greeting-utils"
Step 4: Build and upload
pip install build twine
ython -m build
ython -m twine upload dist/* --username __token__ --password pypi-xxxxx
After upload:
# Test it in a clean environment
pip install greeting-utils
>>> from greeting_utils import say_hello
>>> print(say_hello("Alice"))
Hello, Alice! Welcome to PyPI.
Pro tip: Always test using
pip install -e .in development mode before publishing. This creates an editable install that reflects code changes immediately.
Compare options / when to choose what
| Approach | Best for | Complexity | Use case |
|---|---|---|---|
pyproject.toml + setuptools |
Most modern packages | Low | Standard Python libraries |
setup.py only |
Legacy projects | Medium | Maintaining older packages |
poetry |
New projects with dependency management | Medium | Full-featured package management |
flit |
Simple packages | Low | Pure Python packages with no build requirements |
When to choose what:
- Stick with pyproject.toml + setuptools if you want the easiest path to PyPI with maximum compatibility.
- Use poetry if you also need dependency resolution and virtual environment management in one tool.
- Avoid setup.py alone for new projects — it's being deprecated in favor of pyproject.toml.
Troubleshooting & edge cases
"No module named 'greeting_utils' after install"
- Cause: Your
pyproject.tomlmay not have[tool.setuptools.packages.find]or you're using the wrongname. - Fix: Add
packages = ["greeting_utils"]under[tool.setuptools]:
[tool.setuptools]
packages = ["greeting_utils"]
Upload fails with "400: The description failed to render"
- Cause: Your
README.mdcontains invalid Markdown or HTML that PyPI can't process. - Fix: Validate your README locally:
python -m twine check dist/*
"ERROR: Could not find an activation context" on Windows
- Cause: Missing dependencies in
console_scripts - Fix: Use
entry_pointsinpyproject.tomlfor command-line scripts:
[project.scripts]
greet = "greeting_utils.greet:main"
Version conflicts after upload
- Cause: You bumped the version number incorrectly.
- Fix: Follow Semantic Versioning:
MAJOR.MINOR.PATCH. Usepip install package==1.0.0to pin versions.
What you learned & what's next
You now know how to:
- Structure a Python project for distribution
- Write a
pyproject.tomlthat defines all package metadata - Build your package into distributable artifacts (sdist + wheel)
- Upload to PyPI using
twine - Troubleshoot common packaging pitfalls
What's next? Now that your package is on PyPI, the next lesson covers creating command-line tools with entry points — turning your package into a full-fledged CLI application that users can run directly from their terminal. You'll learn how to define console_scripts and build interactive command-line interfaces.
Practice recap
Create your own small Python utility (e.g., a temperature converter) and publish it to TestPyPI following the steps above. Submit a screenshot of your successful upload to the community forum, along with a link to your package page on test.pypi.org. This will give you confidence for the real PyPI release in the next lesson.
Common mistakes
- Forgetting to include
__init__.py— without it, Python doesn't recognize the directory as a package and imports will fail. - Using
setup.pyalone withoutpyproject.toml— modern tools (pip 21.3+) strongly prefer the new standard and may warn or fail. - Not updating the version number before re-uploading — PyPI rejects duplicate version strings; always bump
MAJOR.MINOR.PATCH. - Hard-coding paths inside your package — use
importlib.resourcesorpkg_resourcesfor accessing data files instead.
Variations
- Use
poetryinstead of plainsetuptools— it handles dependency resolution and virtual environment management automatically. - Publish to TestPyPI first (
test.pypi.org) — this lets you validate the upload and install process before going live. - Use
flitfor ultra-simple packages — it requires minimal configuration and supports pure-Python packages elegantly.
Real-world use cases
- Distributing an internal analytics library across multiple company projects via a private PyPi mirror or Artifactory.
- Releasing an open-source CLI tool (e.g., a JSON formatter) that users can install with
pip install json-tidy. - Publishing a reusable Django or Flask extension (like
django-crispy-forms) that adds functionality via plugin systems.
Key takeaways
- A proper package structure with
__init__.pyandpyproject.tomlis the foundation of Python distribution. - Use
pyproject.tomloversetup.pyfor new projects — it's the modern, supported standard. - Build produces both
.tar.gz(sdist) and.whl(wheel) archives; wheels are faster for installation. - Always test with
pip install -e .in a virtual environment before uploading to PyPI. - Version numbers must follow semantic versioning (e.g.,
0.1.0,1.0.0) and must be unique per release. - Upload with
twine— it's more secure thansetup.py uploadand validates your package before sending.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.