Dynamic Versioning with Poetry: A Complete Guide
Posted on Fri 24 January 2025 in Python • 4 min read
Managing version numbers manually is tedious and error-prone. Every release means updating pyproject.toml, potentially __init__.py, and hoping you didn't forget anything. Dynamic versioning solves this by deriving your package version directly from git tags.
What is Dynamic Versioning?
Dynamic versioning automatically calculates your package version from git tags instead of manually updating version strings. When you tag a release with v2.7.2, your package automatically gets version 2.7.2. For development commits after a tag, you get informative versions like 2.7.3.dev2+g119b386.
Benefits:
- Single source of truth - git tags control versioning
- No manual updates to source files
- Development versions include commit info for debugging
- PEP 440 compliant versions for PyPI
Architecture: Two Contexts
This is the most important concept to understand. Poetry has two separate versioning contexts that don't communicate with each other.
Context 1: Build System (PEP 517/518)
Used by pip install, python -m build, CI/CD builds, and PyPI publishing.
[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"
Context 2: Poetry Plugin System
Used by poetry install, poetry build, and poetry version.
poetry self add poetry-dynamic-versioning
Why you need both: pip install . uses the build-system configuration, while poetry version needs the plugin. If you only configure one, commands using the other context will show 0.0.0.
Installation Guide
Step 1: Install the Poetry Plugin
# Install globally for your Poetry installation
poetry self add poetry-dynamic-versioning
# Verify installation
poetry self show plugins
Step 2: Configure pyproject.toml
Update your build system:
[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"
Set a placeholder version:
[tool.poetry]
name = "your-package-name"
version = "0.0.0" # Placeholder - replaced dynamically
Add the configuration:
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"
pattern = "^v?(?P<base>\\d+\\.\\d+\\.\\d+)"
latest-tag = true
For more control over development versions, use a Jinja template:
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"
pattern = "^v?(?P<base>\\d+\\.\\d+\\.\\d+)"
latest-tag = true
format-jinja = """
{%- if distance == 0 -%}
{{ serialize_pep440(base) }}
{%- else -%}
{{ serialize_pep440(bump_version(base), dev=distance, metadata=[commit]) }}
{%- endif -%}
"""
This produces:
- On tag v2.7.2: version 2.7.2
- 2 commits after tag: version 2.7.3.dev2+g119b386
Runtime Version Handling
The recommended approach uses importlib.metadata, which works correctly with editable installs:
# src/your_package/__init__.py
try:
from importlib.metadata import version
__version__ = version("your-package-name")
except Exception:
__version__ = "0.0.0" # Fallback for development
Why not file substitution? File substitution modifies source files and doesn't work well with editable installs (pip install -e .). The importlib.metadata approach reads from package metadata, which is the source of truth.
CI/CD Integration
Here's a GitHub Actions example for publishing to PyPI:
name: Publish to PyPI
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required: fetch all history for tags
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Poetry
run: pip install poetry
- name: Install plugin
run: poetry self add poetry-dynamic-versioning
- name: Build
run: poetry build
- name: Publish to PyPI
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
run: poetry publish
Critical: The fetch-depth: 0 is essential - without it, git tags won't be available and versioning will fail.
Troubleshooting
poetry version shows 0.0.0
The plugin isn't installed in Poetry's plugin system:
poetry self add poetry-dynamic-versioning
poetry install
Runtime __version__ shows 0.0.0
Your __init__.py isn't using importlib.metadata. Update it as shown above.
No git tags found
git tag v0.1.0
git describe --tags --always
Version works with pip but not poetry
This is expected if you only configured the build-system. You need both:
- Build-system in
pyproject.toml(for pip) - Plugin via
poetry self add(for poetry commands)
Complete Working Example
myproject/
├── pyproject.toml
├── src/
│ └── myproject/
│ └── __init__.py
pyproject.toml:
[tool.poetry]
name = "myproject"
version = "0.0.0"
description = "My project"
authors = ["Your Name <you@example.com>"]
packages = [{include = "myproject", from = "src"}]
[tool.poetry.dependencies]
python = "^3.9"
[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"
pattern = "^v?(?P<base>\\d+\\.\\d+\\.\\d+)"
latest-tag = true
src/myproject/init.py:
try:
from importlib.metadata import version
__version__ = version("myproject")
except Exception:
__version__ = "0.0.0"
Setup commands:
# One-time plugin install
poetry self add poetry-dynamic-versioning
# Create initial tag
git tag v0.1.0
# Install and verify
poetry install
poetry version # myproject 0.1.0
python -c "import myproject; print(myproject.__version__)" # 0.1.0
Best Practices
- Use annotated tags for releases:
git tag -a v1.0.0 -m "Release 1.0.0" - Use 'v' prefix consistently: The pattern
^v?handles both, but consistency helps - Document the setup: Tell contributors to run
poetry self add poetry-dynamic-versioning - Use semantic versioning: Follow semver conventions for predictable versioning
Dynamic versioning removes a category of human error from your release process. Once set up, you simply tag commits to release, and everything else follows automatically.