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:

  1. Build-system in pyproject.toml (for pip)
  2. 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

  1. Use annotated tags for releases: git tag -a v1.0.0 -m "Release 1.0.0"
  2. Use 'v' prefix consistently: The pattern ^v? handles both, but consistency helps
  3. Document the setup: Tell contributors to run poetry self add poetry-dynamic-versioning
  4. 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.