Python Semantic Versioning — Deep Dive
PEP 440 in Detail
Python’s version specification goes beyond simple semver. PEP 440 defines a normalization scheme that pip and other tools use to compare versions:
N[.N]+[{a|b|rc}N][.postN][.devN]
Version Ordering
pip normalizes and orders versions as follows:
1.0.dev1 < 1.0a1 < 1.0b1 < 1.0rc1 < 1.0 < 1.0.post1 < 1.1.dev1
This ordering matters for dependency resolution. A constraint like >=1.0rc1 will match the release candidate and the final release, which is sometimes unintentional.
Epoch Versions
PEP 440 supports epochs for version scheme migrations:
1!1.0.0 # epoch 1, version 1.0.0
An epoch-prefixed version always sorts higher than a non-epoch version. This is a last resort for projects that changed their versioning scheme (e.g., switching from date-based 2024.1 to semver 1.0.0) and need the new scheme to sort higher.
Local Version Identifiers
1.2.3+ubuntu1
1.2.3+build.456
Local versions (after +) are ignored by PyPI and pip’s version comparison. They’re useful for distribution-specific patches (Debian, Conda) that shouldn’t affect upstream resolution.
Automating Version Bumps
python-semantic-release
This tool analyzes commit messages to determine the next version:
# pyproject.toml
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
branch = "main"
commit_message = "chore(release): v{version}"
build_command = "uv build"
It follows the Conventional Commits specification:
fix: correct null handling→ patch bumpfeat: add retry support→ minor bumpfeat!: remove deprecated APIor aBREAKING CHANGE:footer → major bump
CI Integration
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install python-semantic-release
- run: semantic-release version
- run: semantic-release publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The fetch-depth: 0 is critical — semantic-release needs full git history to analyze commits since the last tag.
Commit Linting
Enforce conventional commits with commitlint or pre-commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.1.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [feat, fix, docs, chore, refactor, test, ci]
Without enforcement, developers write freeform messages and the automation breaks.
Determining What Is “Breaking”
Public API Definition
Semver only applies to the public API. Define it explicitly:
# my_package/__init__.py
__all__ = ["Client", "Config", "RequestError"]
Anything not in __all__ or not documented is internal. Changing internal code is a patch, not a major bump — even if someone was importing it.
The _internal Convention
Prefix private modules with underscore:
my_package/
├── __init__.py
├── client.py # public
├── config.py # public
└── _internal/
├── cache.py # private
└── serializer.py # private
If a user imports from _internal, they accept the risk of breakage. Document this boundary in your contributing guide.
Type Annotations as API
A subtle but important question: are type annotations part of the public API? If you change a return type from list[str] to Sequence[str], does that require a major bump?
The emerging consensus: type narrowing (returning a more specific type) is backward-compatible. Type widening (accepting broader input) is backward-compatible. The reverse of either is breaking. Treat your type signatures like function signatures.
Calver vs Semver in the Python Ecosystem
Some major Python projects use calendar versioning (calver):
- pip —
24.0,24.1 - Ubuntu packages —
24.04 - Black —
24.3.0
Calver communicates when a release happened rather than what changed. It works well for projects where every release might contain breaking changes (like a code formatter) or where the “stable API” concept doesn’t apply.
Hybrid Approach
Some projects use calver for the major component and semver semantics for minor/patch:
2026.1.0 → 2026.1.1 (patch) → 2026.2.0 (new features)
This gives temporal context while preserving upgrade-risk signals.
Version Compatibility Testing with Nox
Test your library against the version ranges you claim to support:
# noxfile.py
import nox
@nox.session
@nox.parametrize("pydantic", ["2.0", "2.3", "2.6"])
def test_compat(session, pydantic):
session.install(f"pydantic=={pydantic}", ".")
session.run("pytest", "tests/")
This catches cases where you accidentally use a feature from pydantic 2.5 while claiming >=2.0 compatibility.
Handling Breaking Changes Gracefully
The Adapter Pattern
When a breaking change is unavoidable, ship an adapter in the minor release before the major:
# v1.5 — last minor before v2
from my_package.v2_compat import NewClient as Client # re-export
# Users can optionally switch to:
from my_package.v2_compat import NewClient
This gives users a migration path within the safe v1.x series.
Dual Support Branches
Maintain a 1.x branch for security patches while developing 2.x on main. Backport critical fixes:
git checkout 1.x
git cherry-pick <commit-hash>
git tag v1.9.1
Document an end-of-life date for the 1.x series (e.g., “1.x receives security patches until 2027-01-01”).
Tooling for Version Management
| Tool | Strengths | Limitations |
|---|---|---|
| python-semantic-release | Commit-driven, GitHub/GitLab integration | Requires conventional commits |
| bump2version | Simple, config-file driven | No commit analysis, manual decision |
| versioningit | Derives version from git tags at build time | No version in source files |
| setuptools-scm | git tag → version, widely used | Tight setuptools coupling |
| hatch-vcs | Hatch plugin for git-based versioning | Hatch-only |
Dynamic Versioning with setuptools-scm
Instead of writing the version in pyproject.toml, derive it from git tags:
[build-system]
requires = ["setuptools>=68", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
Tag v1.3.0 in git, and setuptools-scm sets the version at build time. Between tags, it generates versions like 1.3.1.dev4+g1234567 based on commit distance.
Edge Cases and Gotchas
- Yanked releases — PyPI allows yanking a version (marking it as broken) without deleting it. Resolvers skip yanked versions unless explicitly pinned.
- Post-releases —
1.2.3.post1should only fix metadata (README, classifiers), never code. Changing code in a post-release breaks semver expectations. - Pre-release visibility — pip ignores pre-releases by default. Users must opt in with
pip install my-package>=1.3.0a1or--pre. - Version normalization — pip treats
1.2.3,1.2.3.0, andv1.2.3as equivalent. Always use the normalized form.
The one thing to remember: Automate version bumps from commit messages, define your public API boundary explicitly, and test against the version ranges you claim to support — that makes semver a reliable contract instead of a best-effort guess.
See Also
- Python Api Design Principles Design Python functions and classes that feel natural to use — like a well-labeled control panel.
- Python Code Documentation Sphinx Turn Python code comments into a beautiful documentation website automatically.
- Python Docstring Conventions Write helpful notes inside your Python functions so anyone can understand them without reading the code.
- Python Project Layout Conventions Organize Python project files like a tidy toolbox so every teammate finds what they need instantly.
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.