Contents
  1. A Normal Python Project
  2. A Library
  3. A Package
  4. Building and Publishing
  5. The Practical Difference in Summary
  6. What You Can Do Now
← All posts

Python Projects, Libraries, and Packages: What They Are and How to Build Them

A project, a library, and a package are three distinct things in Python. Understanding the difference changes how you structure your code and whether others can install and use it.

The words project, library, and package are used interchangeably in conversation but they mean different things in Python. A project is an application you run. A library is code you write for others to import. A package is the distributable unit that gets published to PyPI and installed with pip. Knowing which one you are building determines how you structure your code, what files you need, and what tooling applies.

A Normal Python Project

A project is code you intend to run, not distribute. A web application, a data pipeline, a script that automates something. It has dependencies, it has an entry point, and the only people who need to install it are the people running it.

The structure of a normal project:

my-project/
├── pyproject.toml
├── poetry.lock         # or uv.lock
├── README.md
├── src/
│   └── my_project/
│       ├── __init__.py
│       └── main.py
└── tests/
    └── test_main.py

The src/ layout places your code one level below the root. This is intentional. It prevents Python from accidentally importing your local source files instead of the installed package during testing, which masks import errors that would surface in production.

pyproject.toml is the single configuration file for modern Python projects. With Poetry it looks like this:

[tool.poetry]
name = "my-project"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]

[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.31"

[tool.poetry.dev-dependencies]
pytest = "^7.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

This project is not published anywhere. You clone it, run poetry install, and run it. That is the complete lifecycle.

A Library

A library is code you write for other developers to import into their own projects. It does not run on its own. It provides functions, classes, or utilities that are useful in other contexts.

The distinction from a project is architectural. A project has an entry point, a command you run. A library has a public API, functions and classes that other code calls. A library also has stricter constraints on its dependencies. A project can pin exact versions because it runs in a controlled environment. A library must declare loose version ranges because it will be installed alongside whatever other packages the consuming project uses, and those packages have their own version requirements.

[tool.poetry.dependencies]
python = "^3.10"
requests = ">=2.28,<3.0"   # loose range, not pinned

A library committed to exact pinned versions will cause dependency resolution failures for anyone who tries to use it alongside other packages.

A Package

A package is a library that has been built into a distributable format and published to PyPI so that anyone can install it with pip install your-package-name. The word package specifically refers to the distributable artifact, not the code itself.

The structure mirrors a library but includes the files required for PyPI:

my-library/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── my_library/
│       ├── __init__.py
│       └── core.py
└── tests/

pyproject.toml for a publishable package requires additional metadata:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-library"
version = "0.1.0"
description = "A short description of what this does"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
  { name = "Your Name", email = "you@example.com" }
]
dependencies = [
  "requests>=2.28"
]

[project.urls]
Homepage = "https://github.com/you/my-library"

The name field must be unique on PyPI. Check that the name is available before building.

Building and Publishing

Install the build tool and twine:

pipx install build
pipx install twine

Build the package. This produces a .whl file (the built distribution) and a .tar.gz file (the source distribution) inside a dist/ directory:

python -m build

Before publishing to PyPI, test against TestPyPI to verify the upload works and the metadata looks correct:

twine upload --repository testpypi dist/*

Then install from TestPyPI to verify the package installs and imports correctly:

pip install --index-url https://test.pypi.org/simple/ my-library

When everything looks correct, publish to PyPI:

twine upload dist/*

You will need an API token from your PyPI account. Store it in ~/.pypirc or pass it as an environment variable rather than entering it interactively each time.

The Practical Difference in Summary

ProjectLibraryPackage
PurposeYou run itOthers import itOthers install it
Entry pointYesNoOptional
Dependency pinningExact versionsLoose rangesLoose ranges
Published to PyPINoNoYes
Has lockfileYesNoNo

A library becomes a package the moment you build and publish it. Until then it is just code organised for reuse.

What You Can Do Now

If you have a utility module you copy between projects, that is a library waiting to be a package. Start there:

# Create the structure
mkdir -p my-library/src/my_library
touch my-library/src/my_library/__init__.py
touch my-library/pyproject.toml
touch my-library/README.md
touch my-library/LICENSE

# Build it
cd my-library
python -m build

# Upload to TestPyPI first
twine upload --repository testpypi dist/*

Once it is on TestPyPI and installs correctly, publishing to PyPI is one command away.

← All posts