Versioning & Release Management
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the Python Reference bibliography. |
Every upload to PyPI is a release identified by a version, and a version can be published only once: a file name, once used, can never be reused, even after deletion. Decide the scheme before the first release and let tooling produce the numbers. This page uses birdtally; the examples ran on 2026-10-22 on Linux with CPython 3.14.6, build 1.6.1, setuptools 84.2.1, setuptools-scm 10.3.4 and Poetry 2.5.1. The version syntax itself (PEP 440 segments, ordering, specifiers) is on Versions & Dependency Specifiers.
Choosing a scheme
-
Semantic versioning (
MAJOR.MINOR.PATCH): increase MAJOR for incompatible changes, MINOR for features, PATCH for fixes. It tells users what an upgrade may break, which is what libraries need; it only works if the project actually follows it. -
Calendar versioning (
2026.10,26.2like pip): the version says when, not what. Good for projects with time-based releases or rolling compatibility. -
Whatever the scheme, the result must be a valid PEP 440 version, or build backends and PyPI refuse it (
poetry version 1.0.0-hotfix.1answersInvalid PEP 440 version: '1.0.0-hotfix.1';1.0.0.post1is the PEP 440 spelling of a hotfix to the packaging of 1.0.0). -
Pre-releases (
2.0.0a1,2.0.0b1,2.0.0rc1) are skipped by installers unless asked for (pip install --pre,allow-prereleases) or pinned; publish them to let users test. -
Post-releases (
1.0.0.post1) fix packaging or metadata without code changes; dev releases (2.0.0.dev3) are for snapshots. -
Local versions (
1.0.0+ubuntu1,0.2.1.dev1+g6af930be7) are for builds that never leave a machine or a private index: PyPI rejects them. -
Epochs (
1!2.0) reset the ordering after a scheme change; avoid them, few people recognise them.
Single-sourcing the version
The version should be written in one place. Three ways:
| Where | How |
|---|---|
|
|
The Git tag |
|
The source code |
|
From Git tags with setuptools-scm
[build-system]
requires = ["setuptools>=77.0.3", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "birdtally"
dynamic = ["version"]
# …
[tool.setuptools_scm]
version_file = "src/birdtally/_version.py"
git tag v0.2.0
python -m build -w # on the tagged commit
git commit -am "next"; python -m build -w # one commit later
echo dirty >> README.md; python -m build -w # with uncommitted changes
Successfully built birdtally-0.2.0-py3-none-any.whl
Successfully built birdtally-0.2.1.dev1+g6af930be7-py3-none-any.whl
Successfully built birdtally-0.2.1.dev1+g6af930be7.d20261022-py3-none-any.whl
On the tag, the version is the tag. After it, the default scheme guesses the next patch version as a dev release and adds
the commit hash (g6af930be7) and, for a dirty tree, the date as a local version, so these builds can never reach PyPI by
accident. Tags may carry a prefix (v0.2.0); monorepos use a per-package tag prefix. The generated _version.py ("don’t track
in version control") is ignored by Git and written at build time. The build needs the Git history: a shallow CI checkout without
tags builds 0.1.dev1+g6af930be7 (tried with git clone --depth 1 --no-tags), so fetch the history and tags (fetch-depth: 0
in actions/checkout).
At runtime
Read the installed version from the metadata, not from a constant that can drift:
from importlib.metadata import version
__version__ = version("birdtally")
A test that birdtally.__version__ == importlib.metadata.version("birdtally") catches the classic mistake this example had:
the 0.2.0 wheel built from the tag still carried a hard-coded __version__ = "0.1.0" in __init__.py:
0.2.0 0.1.0 0.2.0
(metadata version, birdtally.__version__, birdtally._version.__version__).
poetry version
poetry version prints or changes the version in pyproject.toml (--short prints the number only, --dry-run writes nothing).
From 1.0.2:
patch Bumping version from 1.0.2 to 1.0.3
minor Bumping version from 1.0.2 to 1.1.0
major Bumping version from 1.0.2 to 2.0.0
prepatch Bumping version from 1.0.2 to 1.0.3a0
preminor Bumping version from 1.0.2 to 1.1.0a0
premajor Bumping version from 1.0.2 to 2.0.0a0
prerelease Bumping version from 1.0.2 to 1.0.3a0
A release cycle through the pre-release phases with prerelease and --next-phase (1.7):
poetry version prerelease
poetry version prerelease
poetry version --next-phase prerelease
poetry version --next-phase prerelease
poetry version patch
Bumping version from 1.0.2 to 1.0.3a0
Bumping version from 1.0.3a0 to 1.0.3a1
Bumping version from 1.0.3a1 to 1.0.3b0
Bumping version from 1.0.3b0 to 1.0.3rc0
Bumping version from 1.0.3rc0 to 1.0.3
Any other argument must be a PEP 440 version and is set as is (poetry version 2.0.0.dev1). Two traps found here: prerelease
on a dev release (2.0.0.dev1) crashes with an AssertionError in Poetry 2.5.1, and poetry version 1.0.0+local is accepted
although PyPI will reject the upload. For a local version only in the build, use
poetry build --config-settings local-version=… (-l/--local-version is deprecated). A dynamic version (set by a plugin or by --config-settings) needs
dynamic = ["version"] and a base version in [tool.poetry].
Dropping an old Python
Raise requires-python in the release that stops supporting a Python (and drop the matching classifiers); installers on the old
Python then pick the last release that still allowed it. Before publishing, check that the field reached the metadata:
tar xzf dist/birdtally-*.tar.gz --wildcards -O 'birdtally-*/PKG-INFO' | grep Requires-Python (prints
Requires-Python: >=3.10) (or unzip -p on the wheel’s METADATA).
Never publish a release whose requires-python claims support it does not have: a wrong release cannot be fixed, only yanked.
Deprecations and the changelog
-
Deprecate before removing: emit
DeprecationWarning(orFutureWarningfor end users) for at least one minor release, say in the message what to use instead and in which version the old API goes, and remove it in the next major release. -
Keep a human-written
CHANGELOG.md(the Keep a Changelog format is common), link it asChangelogin[project.urls]so PyPI shows it, and ship it in the sdist. -
Tag the release commit (
v0.2.0) and create the release from the tag in CI; see GitHub Actions & GitLab CI Release Workflows.