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:
- Reads all commits between
HEADand the last version tag - Parses each commit against the pattern
- Collects the highest bump level (MAJOR > MINOR > PATCH)
- 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
| Feature | Commitizen | python-semantic-release | standard-version (JS) |
|---|---|---|---|
| Language | Python | Python | Node.js |
| Interactive commit | ✅ cz commit | ❌ | ❌ |
| Version bump | ✅ cz bump | ✅ semantic-release | ✅ |
| Changelog | ✅ built-in | ✅ built-in | ✅ built-in |
| PyPI publish | ❌ (separate step) | ✅ built-in | N/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.
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.