Proper versioning communicates to users what changes to expect, helps dependency resolution work correctly, and makes your package's history understandable. This guide covers versioning strategies, tools, and best practices for Python packages.
Understanding Version Numbers
A version number tells users about compatibility and change magnitude:
┌─────────────────────────────────────────────────────────────┐
│ Version Anatomy │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1 . 2 . 3 a1 │
│ │ │ │ │ │
│ │ │ │ └── Pre-release (optional) │
│ │ │ │ │
│ │ │ └── PATCH: Bug fixes │
│ │ │ │
│ │ └── MINOR: New features (backward compat) │
│ │ │
│ └── MAJOR: Breaking changes │
│ │
└─────────────────────────────────────────────────────────────┘
Semantic Versioning (SemVer)
Semantic Versioning is the most common scheme for libraries. The format is MAJOR.MINOR.PATCH:
Version Component Meanings
| Component | Increment When | Example |
|---|---|---|
| MAJOR | Breaking API changes | 1.0.0 → 2.0.0 |
| MINOR | New features (backward compatible) | 1.0.0 → 1.1.0 |
| PATCH | Bug fixes (backward compatible) | 1.0.0 → 1.0.1 |
What Counts as a Breaking Change?
# Breaking changes (increment MAJOR):
# Removing a public function
def calculate(x): # removed → MAJOR bump
pass
# Changing function signature
def process(data): # before
def process(data, strict): # after (required param) → MAJOR bump
# Changing return type
def get_value() -> int: # before
def get_value() -> str: # after → MAJOR bump
# Changing exception types
def load():
raise ValueError() # before
raise TypeError() # after → MAJOR bump
# Non-breaking changes (MINOR or PATCH):
# Adding optional parameter
def process(data, strict=False): # MINOR (new feature)
# Adding new function
def new_feature(): # MINOR
# Fixing bug in existing function
def calculate(x):
return x + 1 # was x + 2 (bug) → PATCH
The 0.x Exception
Before version 1.0.0, anything goes:
0.1.0 → 0.2.0 # Can have breaking changes
0.9.0 → 0.10.0 # Still unstable
0.99.0 → 1.0.0 # Now you're committing to stability
Use 0.x versions while your API is experimental. Reach 1.0.0 when you're confident in your API.
Calendar Versioning (CalVer)
CalVer uses dates in version numbers. It works well for:
- Applications (not libraries)
- Projects with regular releases
- When "breaking changes" isn't meaningful
Common CalVer Formats
| Format | Example | Used By |
|---|---|---|
YYYY.MM.DD | 2026.01.15 | Daily releases |
YYYY.MM | 2026.01 | Monthly releases |
YY.MM | 26.01 | Shorter format |
YYYY.MINOR | 2026.1 | Year + sequence |
Real-World CalVer Examples
# Ubuntu: YY.MM
20.04, 22.04, 24.04
# pip: YY.MINOR
23.0, 23.1, 23.2, 24.0
# Black (formatter): YY.MM.MICRO
23.1.0, 23.3.0, 23.7.0
Implementing CalVer
# pyproject.toml
[project]
name = "my-app"
version = "2026.01.0" # Year.Month.Release
For automatic CalVer, use setuptools-scm with a custom scheme.
Pre-Release Versions
Pre-releases let users test upcoming versions without affecting stable installs.
Pre-Release Types
1.0.0a1 # Alpha: early testing, unstable
1.0.0a2 # Alpha 2
1.0.0b1 # Beta: feature complete, testing
1.0.0b2 # Beta 2
1.0.0rc1 # Release candidate: almost ready
1.0.0rc2 # Release candidate 2
1.0.0 # Final release
Sort Order
PEP 440 defines the sort order:
1.0.0.dev1 < 1.0.0a1 < 1.0.0b1 < 1.0.0rc1 < 1.0.0 < 1.0.0.post1
Installing Pre-Releases
# By default, pip ignores pre-releases
pip install my-package # Gets 1.0.0 (stable)
# Explicitly request pre-releases
pip install --pre my-package # May get 1.1.0a1
# Pin to specific pre-release
pip install my-package==1.1.0a1
Development and Post Versions
Development Versions
For unreleased code between versions:
1.0.0.dev1 # Development version
1.0.0.dev2
1.0.0.dev10
1.0.0 # Released version
Used by setuptools-scm for commits after a tag:
v1.0.0 tag → version 1.0.0
3 commits later → version 1.0.1.dev3+g1a2b3c4
Post Versions
For packaging-only fixes (no code changes):
1.0.0 # Original release
1.0.0.post1 # Fixed README or classifiers
1.0.0.post2 # Another packaging fix
Use sparingly—a new patch version is usually clearer.
Dynamic Versioning with setuptools-scm
setuptools-scm derives version from git tags, eliminating manual version management:
Setup
# pyproject.toml
[build-system]
requires = ["setuptools>=64", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
dynamic = ["version"]
[tool.setuptools_scm]
# Version written to file for runtime access
version_file = "src/my_package/_version.py"
How It Works
# Tag a release
git tag v1.2.3
git push --tags
# Build package
python -m build
# → my_package-1.2.3-py3-none-any.whl
# After more commits (no tag)
python -m build
# → my_package-1.2.4.dev2+g1a2b3c4-py3-none-any.whl
Accessing Version at Runtime
# src/my_package/__init__.py
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
Version Bumping Tools
bump2version (bumpversion)
Automates version updates across files:
# .bumpversion.cfg
[bumpversion]
current_version = 1.2.3
commit = True
tag = True
[bumpversion:file:pyproject.toml]
[bumpversion:file:src/my_package/__init__.py]
# Install
pip install bump2version
# Bump patch: 1.2.3 → 1.2.4
bump2version patch
# Bump minor: 1.2.3 → 1.3.0
bump2version minor
# Bump major: 1.2.3 → 2.0.0
bump2version major
tbump
Modern alternative with TOML config:
# tbump.toml
[version]
current = "1.2.3"
[git]
push = true
tag_template = "v{new_version}"
[[file]]
src = "pyproject.toml"
[[file]]
src = "src/my_package/__init__.py"
search = '__version__ = "{current_version}"'
replace = '__version__ = "{new_version}"'
pip install tbump
# Bump to specific version
tbump 1.3.0
poetry version
If using Poetry:
# Bump patch: 1.2.3 → 1.2.4
poetry version patch
# Bump minor: 1.2.3 → 1.3.0
poetry version minor
# Bump major: 1.2.3 → 2.0.0
poetry version major
# Set specific version
poetry version 2.0.0
# Pre-release
poetry version prepatch # 1.2.3 → 1.2.4a0
Changelog Management
A changelog documents what changed between versions.
Keep a Changelog Format
The standard format from keepachangelog.com:
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- New feature X
## [1.2.0] - 2026-01-15
### Added
- Support for Python 3.13
- New `process_async()` function
### Changed
- Improved performance of `calculate()` by 50%
### Deprecated
- `old_function()` will be removed in 2.0.0
### Fixed
- Fixed memory leak in long-running processes
- Corrected typo in error message
## [1.1.0] - 2025-12-01
### Added
- Initial async support
Change Categories
| Category | Description |
|---|---|
| Added | New features |
| Changed | Changes to existing functionality |
| Deprecated | Features that will be removed |
| Removed | Features that were removed |
| Fixed | Bug fixes |
| Security | Security vulnerability fixes |
Automating with Towncrier
Towncrier generates changelogs from fragment files:
# pyproject.toml
[tool.towncrier]
directory = "changelog.d"
filename = "CHANGELOG.md"
start_string = "<!-- towncrier release notes start -->\n"
underlines = ["", "", ""]
template = "changelog.d/template.md"
title_format = "## [{version}] - {project_date}"
issue_format = "[#{issue}](https://github.com/user/repo/issues/{issue})"
[[tool.towncrier.type]]
directory = "added"
name = "Added"
showcontent = true
[[tool.towncrier.type]]
directory = "changed"
name = "Changed"
showcontent = true
[[tool.towncrier.type]]
directory = "fixed"
name = "Fixed"
showcontent = true
Create fragment files for each change:
# changelog.d/123.added
Added support for Python 3.13
# changelog.d/124.fixed
Fixed memory leak in long-running processes (#124)
Build changelog:
towncrier build --version 1.2.0
# Compiles fragments into CHANGELOG.md and deletes them
Version Constraints in Dependencies
When depending on other packages, choose constraints carefully:
Constraint Types
| Specifier | Meaning | Example |
|---|---|---|
== | Exact version | ==1.2.3 |
>= | Minimum version | >=1.2.0 |
<= | Maximum version | <=2.0.0 |
!= | Exclude version | !=1.2.4 |
~= | Compatible release | ~=1.2.0 means >=1.2.0,<1.3.0 |
>=,< | Range | >=1.2.0,<2.0.0 |
Recommendations by Project Type
# For Libraries (be permissive)
[project]
dependencies = [
"requests>=2.28", # Minimum only
"click>=8.0,<9.0", # Range with major cap
]
# For Applications (can be stricter)
[project]
dependencies = [
"requests>=2.28.0,<3.0.0",
"click>=8.1.0,<9.0.0",
]
# For Reproducible Builds (pin everything)
# Use a lock file instead (poetry.lock, requirements.txt)
The Compatible Release Operator (~=)
~=1.2.3 # Equivalent to >=1.2.3,<1.3.0
~=1.2 # Equivalent to >=1.2,<2.0
~=1 # Equivalent to >=1,<2
Use for dependencies that follow SemVer properly.
Choosing a Versioning Strategy
Decision Tree
Is your project a reusable library?
├── Yes → Use SemVer (1.2.3)
│ Users depend on your API stability
│
└── No → Is backward compatibility meaningful?
├── Yes → Use SemVer
│
└── No → Consider CalVer (2026.01)
Good for applications, tools
Common Patterns by Project Type
| Project Type | Recommended Scheme | Example |
|---|---|---|
| Library (PyPI) | SemVer | requests 2.31.0 |
| CLI Tool | SemVer or CalVer | black 23.7.0 |
| Web App | CalVer | 2026.01.15 |
| Framework | SemVer | django 5.0.1 |
| Data Pipeline | CalVer | 2026.1 |
Best Practices
1. Start at 0.1.0
Don't start at 1.0.0 unless your API is stable:
0.1.0 # Initial development
0.2.0 # Add features freely
0.9.0 # Getting close to stable
1.0.0 # API is now stable
2. Document Breaking Changes
## [2.0.0] - 2026-01-15
### BREAKING CHANGES
- Removed deprecated `old_function()`. Use `new_function()` instead.
- Changed `process()` to require `strict` parameter.
- Minimum Python version is now 3.10.
### Migration Guide
See [MIGRATION.md](MIGRATION.md) for upgrade instructions.
3. Use Pre-Releases for Testing
# Release alpha for early feedback
bump2version --new-version 2.0.0a1 major
# After testing, release beta
bump2version --new-version 2.0.0b1 major
# Release candidate
bump2version --new-version 2.0.0rc1 major
# Final release
bump2version --new-version 2.0.0 major
4. Never Reuse Version Numbers
Even if you yank a release, that version is forever taken:
1.2.0 # Released with bug
1.2.0 # Cannot re-upload fixed version!
1.2.1 # Must use new version for fix
5. Automate Version Management
Use CI/CD to enforce versioning:
# .github/workflows/release.yml
- name: Check version matches tag
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
PKG_VERSION=$(python -c "import my_package; print(my_package.__version__)")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "Version mismatch: tag=$TAG_VERSION, package=$PKG_VERSION"
exit 1
fi
Version in pyproject.toml
Static Version
[project]
name = "my-package"
version = "1.2.3"
Dynamic Version
[project]
name = "my-package"
dynamic = ["version"]
# With setuptools-scm
[tool.setuptools_scm]
# Or read from __init__.py
[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}
Common Versioning Mistakes
Mistake 1: Inconsistent Version Locations
# pyproject.toml says 1.2.3
# __init__.py says 1.2.2
# Bad! Use dynamic versioning or bump tools
Mistake 2: Breaking Changes in Minor Version
# 1.2.0
def process(data):
return result
# 1.3.0 - WRONG! This is a breaking change
def process(data, required_param): # New required param
return result
# Correct: This should be 2.0.0
Mistake 3: Not Using Pre-Releases
1.0.0
2.0.0 # Big breaking release, users surprised
# Better approach:
1.0.0
2.0.0a1 # Users can test
2.0.0b1 # More testing
2.0.0rc1 # Almost ready
2.0.0 # Users are prepared
Next Steps
- Learn to Publish to PyPI with proper versions
- See our pyproject.toml Guide for configuration
- Explore the Python Packaging Complete Guide for the full ecosystem
For more Python development guides, explore our complete Python packaging series.