Conventional Commits: Stop Writing Useless Git Messages Forever
Conventional Commits standardize git messages for automated changelogs and semantic versioning.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Basic Git usage (init, add, commit, push)
- ✓Familiarity with command line
Conventional Commits is a standard for writing commit messages like feat: add user login or fix(api): handle null pointer. It makes your git history machine-readable and human-friendly, enabling automatic version bumps and changelog generation.
Think of commit messages as labels on jars in a pantry. Without a system, you get jars labeled 'stuff' or 'things'. Conventional Commits is like labeling each jar with its contents (type: feat, fix) and the specific shelf (scope: auth, api). Now you can instantly find what you need and know if opening a jar will break your recipe (breaking change).
Your commit history is a mess. I've seen teams waste hours digging through 'fixed stuff' and 'updated code' trying to find when a bug was introduced. That's not just annoying — it's dangerous when you need to revert a change in production at 2 AM. Conventional Commits fixes this by giving every commit a structured, meaningful label.
The problem it solves is simple: unstructured commit messages are useless for automation. Without a standard, you can't auto-generate changelogs, you can't automatically determine the next semantic version, and you can't easily filter commits by type. Teams end up manually curating release notes — a tedious, error-prone process.
By the end of this, you'll be able to write Conventional Commits from memory, set up a commitlint hook to enforce them, and integrate with semantic-release to automate versioning and changelogs. You'll never look at a 'fixed bug' commit the same way again.
The Anatomy of a Conventional Commit
Before we dive into tooling, you need to understand the format. It's dead simple: <type>(<scope>): <description>. The type is mandatory. Scope is optional but recommended. Description is a short summary in imperative mood. Then you can add a body and footer after a blank line.
Here's the full structure: ``` <type>(<scope>): <description>
[optional body]
[optional footer(s)] ```
The key types are: feat (new feature), fix (bug fix), chore (maintenance), docs (documentation), style (formatting), refactor (code change that neither fixes nor adds), test (adding tests), perf (performance improvement).
Breaking changes are indicated by appending ! after the type/scope, or by adding BREAKING CHANGE: in the footer. This triggers a major version bump in semantic versioning.
git commit -m with a multi-line message. It's easy to forget the blank line before the body. Use git commit without -m to open an editor, or use -m for the subject and -m again for the body: git commit -m "feat: add login" -m "Implements OAuth2 flow."Why Bother? The Automation Payoff
The real value of Conventional Commits isn't just readability — it's automation. Once your commits are structured, tools can parse them to:
- Auto-generate changelogs: Tools like
standard-versionorsemantic-releasescan your git log, group commits by type, and produce a beautiful CHANGELOG.md. - Automate semantic versioning: Based on the commits since the last release, the tool determines if it's a patch (fix), minor (feat), or major (breaking change) and bumps the version accordingly.
- Filter commits for release notes: You can easily see all features, fixes, or breaking changes in a release.
Without this, you're manually curating release notes — a task that gets skipped or done poorly. I've seen teams forget to mention breaking changes, causing production outages when consumers don't update their code.
semantic-release for full CI/CD integration. It runs in your pipeline, analyzes commits since the last release, and publishes to npm (or any registry) automatically. No more manual version bumps.Enforcing the Standard with Commitlint
You can't rely on everyone remembering the format. That's where commitlint comes in. It's a linter for commit messages. You hook it into your workflow via husky (Git hooks) so that every commit is validated before it's accepted.
Here's the setup: 1. Install commitlint and the conventional config. 2. Install husky and configure the commit-msg hook. 3. Write a commitlint.config.js file.
Now, if someone writes git commit -m "fixed bug", commitlint rejects it with a clear error message. The developer must rewrite it properly.
Semantic Versioning and Breaking Changes
fixtype → PATCH release (1.0.0 → 1.0.1)feattype → MINOR release (1.0.0 → 1.1.0)- Breaking change (
!orBREAKING CHANGEfooter) → MAJOR release (1.0.0 → 2.0.0)
This is powerful because it removes human judgment from versioning. The tool decides based on the commits. But you must be disciplined: if you mark a commit as feat but it's actually a breaking change, you'll accidentally release a minor version instead of major. I've seen this cause downstream breakage when consumers auto-update.
Always use ! for breaking changes. Example: feat!: remove deprecated login endpoint. This ensures the next version is major.
BREAKING CHANGE in the subject line. It must be in the footer. The subject line uses ! after the type/scope. Mixing them up confuses parsers.Scopes: When and How to Use Them
Scopes are optional but incredibly useful for large projects. They indicate the area of the codebase affected. Examples: feat(auth): add OAuth2 login, fix(api): handle timeout, chore(deps): update lodash.
Scopes should be nouns that map to modules, components, or layers. Keep them consistent. I recommend defining a list of allowed scopes in commitlint config to prevent typos.
When not to use scopes: if your project is small (single module), scopes add noise. Also, avoid overly granular scopes like fix(src/utils/helpers/string): fix typo — that's too specific. Stick to high-level areas.
commitlint with scope-enum rule to enforce consistent scopes. This prevents 'fix(auth)' vs 'fix(Auth)' vs 'fix(authentication)' — all referring to the same thing.Integrating with CI/CD Pipelines
Enforcing commit messages locally is good, but you should also validate them in CI. This catches commits that bypass hooks (e.g., someone commits with --no-verify).
In GitHub Actions, you can use the commitlint action. In GitLab CI, run npx commitlint --from $CI_COMMIT_BEFORE_SHA --to $CI_COMMIT_SHA. This validates all commits in the push.
Here's a GitHub Actions workflow that runs on pull requests:
When NOT to Use Conventional Commits
Conventional Commits is overkill for personal projects or small teams that don't release often. If you're the only developer and you never publish a package, the overhead of enforcing a standard isn't worth it.
Also, if your team is not bought in, forcing it will lead to resentment and bypasses. Start with a trial period on a new project, or introduce it alongside tooling that provides immediate value (like auto-changelog).
For monorepos, consider using tools like lerna or changesets that work with Conventional Commits but add scope management.
The 2 AM Revert That Took an Hour
git log --oneline --grep='^fix(checkout)' to instantly find the bug fix commit. Reverts take seconds.- A vague commit message is a time bomb.
- When production is down, clarity is speed.
echo "feat: add login" | npx commitlint to test locally.git log --oneline. 2. Look for missing ! on breaking changes. 3. Verify that fix and feat types are used correctly. 4. Run npx semantic-release --dry-run to preview the next version.npx husky install in the repo. 2. Ensure prepare script is in package.json: "prepare": "husky install". 3. Check .husky/ directory exists and contains the hook scripts.echo "feat: add login" | npx commitlintgit log -1 --format=%s | npx commitlintgit commit --amend -m "feat: add login"| File | Command / Code | Purpose |
|---|---|---|
| example-commits.sh | git commit -m "feat: add user authentication" | The Anatomy of a Conventional Commit |
| auto-changelog.sh | npm install --save-dev standard-version | Why Bother? The Automation Payoff |
| setup-commitlint.sh | npm install --save-dev @commitlint/cli @commitlint/config-conventional | Enforcing the Standard with Commitlint |
| breaking-change.sh | git commit -m "feat!: drop support for Node 10" | Semantic Versioning and Breaking Changes |
| commitlint.config.js | module.exports = { | Scopes |
| .github | name: Commitlint | Integrating with CI/CD Pipelines |
Key takeaways
<type>(<scope>): <description> with optional body and footer.! or BREAKING CHANGE footer to trigger major version bumps.Interview Questions on This Topic
How does Conventional Commits handle breaking changes when there are multiple breaking changes in one commit?
BREAKING CHANGE:. The parser will detect all of them. However, it's better practice to have one breaking change per commit to keep history clean.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Git. Mark it forged?
3 min read · try the examples if you haven't