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.2 like 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.1 answers Invalid PEP 440 version: '1.0.0-hotfix.1'; 1.0.0.post1 is 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

pyproject.toml

version = "0.1.0" in [project]. Simple; bump it with poetry version or by hand. All backends support it.

The Git tag

dynamic = ["version"] and a VCS plugin: setuptools-scm, hatch-vcs (or Hatch’s version source), pdm-backend’s source = "scm". Tag the commit, build, and the version follows. uv_build does not support dynamic versions.

The source code

dynamic = ["version"] and the backend reads __version__ (flit-core does by default, setuptools with [tool.setuptools.dynamic] version = {attr = …}, hatchling with [tool.hatch.version] path).

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
flowchart LR A["1.0.2"] -- prerelease --> B["1.0.3a0"] B -- prerelease --> C["1.0.3a1"] C -- "--next-phase prerelease" --> D["1.0.3b0"] D -- "--next-phase prerelease" --> E["1.0.3rc0"] E -- patch --> F["1.0.3"] F -- minor --> G["1.1.0"] F -- major --> H["2.0.0"] F -- premajor --> I["2.0.0a0"]

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 (or FutureWarning for 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 as Changelog in [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.