Python Commitizen Conventional Commits — Deep Dive

How Commitizen Parses Commits

Commitizen uses regex patterns to parse commit messages. The default cz_conventional_commits rule maps types to semantic version increments:

# Simplified internal logic
BUMP_MAP = {
    "feat": "MINOR",
    "fix": "PATCH",
    "perf": "PATCH",
    "refactor": None,   # no bump
    "docs": None,
    "test": None,
    "ci": None,
}
# BREAKING CHANGE footer or ! suffix → MAJOR

When cz bump runs, it:

  1. Reads all commits between HEAD and the last version tag
  2. Parses each commit against the pattern
  3. Collects the highest bump level (MAJOR > MINOR > PATCH)
  4. Applies that bump to the current version

If no bump-worthy commits exist, cz bump exits with a non-zero code — useful for CI pipelines that should skip releases when nothing changed.

Custom Commit Rules

Organizations often need domain-specific commit types. Create a custom rule:

# cz_custom.py
from commitizen.cz.conventional_commits import ConventionalCommitsCz

class CustomCz(ConventionalCommitsCz):
    # Add custom types
    change_type_map = {
        "feat": "Features",
        "fix": "Bug Fixes",
        "perf": "Performance",
        "security": "Security",      # custom
        "deprecate": "Deprecations",  # custom
    }

    bump_map = {
        "feat": "MINOR",
        "fix": "PATCH",
        "perf": "PATCH",
        "security": "PATCH",
        "deprecate": "MINOR",
    }

    def questions(self):
        questions = super().questions()
        # Extend the type choices
        for q in questions:
            if q["name"] == "prefix":
                q["choices"].extend([
                    {"value": "security", "name": "security: A security fix or improvement"},
                    {"value": "deprecate", "name": "deprecate: Mark feature for removal"},
                ])
        return questions


discover_this = CustomCz

Register it in pyproject.toml:

[tool.commitizen]
name = "cz_custom"

CI/CD Release Pipeline

A production GitHub Actions workflow with Commitizen:

# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # full history for bump calculation
          token: ${{ secrets.GH_PAT }}  # token with push permissions

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install commitizen

      - name: Bump version (if applicable)
        id: bump
        run: |
          cz bump --yes --changelog 2>&1 | tee bump_output.txt
          if grep -q "NO_COMMITS_TO_BUMP" bump_output.txt; then
            echo "bumped=false" >> "$GITHUB_OUTPUT"
          else
            echo "bumped=true" >> "$GITHUB_OUTPUT"
            echo "version=$(cz version --project)" >> "$GITHUB_OUTPUT"
          fi

      - name: Push tag and changelog
        if: steps.bump.outputs.bumped == 'true'
        run: |
          git push origin main --tags

      - name: Create GitHub Release
        if: steps.bump.outputs.bumped == 'true'
        env:
          GH_TOKEN: ${{ secrets.GH_PAT }}
        run: |
          VERSION=${{ steps.bump.outputs.version }}
          cz changelog --start-rev "v$VERSION" --dry-run > release_notes.md
          gh release create "v$VERSION" \
            --title "v$VERSION" \
            --notes-file release_notes.md

This pipeline:

  • Only bumps when bump-worthy commits exist
  • Creates a Git tag and updates CHANGELOG.md
  • Publishes a GitHub Release with extracted notes

Monorepo Versioning

For monorepos with multiple packages, Commitizen supports scoped bumps. The scope in commit messages maps to packages:

feat(payments): add recurring billing support
fix(auth): handle expired refresh tokens

Configure per-package versioning:

# packages/payments/pyproject.toml
[tool.commitizen]
name = "cz_conventional_commits"
version = "2.1.0"
version_files = ["src/payments/__init__.py:__version__"]
tag_format = "payments-v$version"

Bump a specific package:

cd packages/payments
cz bump --git-output-to-stderr

The tag_format prefix prevents tag collisions between packages.

Filtering Commits by Scope

Use cz bump --scope payments to only consider commits with the payments scope. Combined with the tag format, each package maintains an independent version history.

Changelog Customization

Control changelog output:

[tool.commitizen]
changelog_incremental = true       # append to existing changelog
changelog_start_rev = "v1.0.0"    # ignore commits before this
update_changelog_on_bump = true    # auto-update during cz bump

Custom Changelog Template

Commitizen uses Jinja2 templates for changelog rendering:

[tool.commitizen]
changelog_template = "templates/CHANGELOG.md.j2"
{# templates/CHANGELOG.md.j2 #}
# Changelog

{% for version, changes in tree.items() %}
## {{ version.tag }} ({{ version.date }})

{% for change_type, commits in changes.items() %}
### {{ change_type }}

{% for commit in commits %}
- {{ commit.scope }}: {{ commit.message }} ([{{ commit.rev[:7] }}](https://github.com/org/repo/commit/{{ commit.rev }}))
{% endfor %}
{% endfor %}
{% endfor %}

This adds commit hash links and scope prefixes — useful for navigating large changelogs.

Pre-Commit Hooks: Beyond Message Validation

Commitizen offers two hooks:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/commitizen-tools/commitizen
    rev: v3.29.0
    hooks:
      - id: commitizen           # validate message format
      - id: commitizen-branch    # validate branch naming
        stages: [push]

The commitizen-branch hook enforces branch naming conventions tied to commit types (e.g., feat/add-auth, fix/null-pointer).

Commitizen vs. python-semantic-release vs. Standard Version

FeatureCommitizenpython-semantic-releasestandard-version (JS)
LanguagePythonPythonNode.js
Interactive commitcz commit
Version bumpcz bumpsemantic-release
Changelog✅ built-in✅ built-in✅ built-in
PyPI publish❌ (separate step)✅ built-inN/A
Custom rules✅ Python classes⚠️ config-based⚠️ config-based
Pre-commit hook

Commitizen excels at the developer experience (interactive prompts, pre-commit enforcement). python-semantic-release is more focused on the CI side (build, publish, release in one command). Many teams use Commitizen for commits and python-semantic-release for releases.

Edge Cases and Gotchas

Squash merges destroy commit types. If your team uses squash merges on PRs, only the squash commit message matters. Ensure PR titles follow Conventional Commits format, and configure GitHub to use the PR title as the squash commit message.

Rebase workflows and duplicate bumps. If a feature branch has multiple feat: commits that get rebased onto main, cz bump counts each one. This is correct behavior — each commit represents a change — but teams used to squash merging may be surprised.

Initial development (0.x). During pre-release development, Commitizen treats MINOR as PATCH and MAJOR as MINOR when the version starts with 0.. This follows semver’s pre-1.0 convention. Configure major_version_zero = true in pyproject.toml to enable this.

One thing to remember: Commitizen turns git history into a release automation API — structured commits in, versioned releases out, with the developer experience to make the structure stick.

pythongitrelease-managementdeveloper-tools

See Also

  • Python Black Formatter Understand Black Formatter through a practical analogy so your Python decisions become faster and clearer.
  • Python Bumpversion Release Change your software's version number in every file at once with a single command — no more find-and-replace mistakes.
  • Python Changelog Automation Let your git commits write the changelog so you never forget what changed in a release.
  • Python Ci Cd Python Understand CI CD Python through a practical analogy so your Python decisions become faster and clearer.
  • Python Cicd Pipelines Use Python CI/CD pipelines to remove setup chaos so Python projects stay predictable for every teammate.