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 bump
  • feat: add retry support → minor bump
  • feat!: remove deprecated API or a BREAKING 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):

  • pip24.0, 24.1
  • Ubuntu packages24.04
  • Black24.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

ToolStrengthsLimitations
python-semantic-releaseCommit-driven, GitHub/GitLab integrationRequires conventional commits
bump2versionSimple, config-file drivenNo commit analysis, manual decision
versioningitDerives version from git tags at build timeNo version in source files
setuptools-scmgit tag → version, widely usedTight setuptools coupling
hatch-vcsHatch plugin for git-based versioningHatch-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-releases1.2.3.post1 should 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.0a1 or --pre.
  • Version normalization — pip treats 1.2.3, 1.2.3.0, and v1.2.3 as 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.

pythonversioningpackaging

See Also