What you'll learn
Quick Answer
Conventional Commits is a lightweight convention for commit messages: a type such as feat or fix, an optional scope in parentheses, then a colon and a short description. It makes history easy to scan and lets tools derive changelogs and the next version number automatically. feat maps to a minor release, fix to a patch, and a breaking change to a major one.
The format
Every Conventional Commit follows one shape:
<type>(<optional scope>): <description>
[optional body]
[optional footer(s)]A real example:
feat(auth): add OTP login for students
Sends a 6-digit code valid for 10 minutes. Falls back to
email if the SMS provider returns an error.
Refs: PRIO-482The first line is the only required part. Per the v1.0.0 spec the type is a noun, followed by an optional scope, an optional !, and a required colon and space; the description then follows immediately. A body, if present, starts one blank line after the description. Footers come one blank line after the body, each a token such as Refs or Reviewed-by that uses hyphens in place of spaces.
Footers carry metadata tooling reads: Co-authored-by: for shared work, a review sign-off, or an issue link. The one footer with special meaning is BREAKING CHANGE:, covered below. Everything past the first line is optional, so the smallest valid commit is just fix: correct typo in login copy.
The types
The spec mandates only two types and borrows the rest from the Angular convention:
featis a new feature. It correlates with a MINOR bump under SemVer.fixis a bug fix. It correlates with a PATCH bump.docs,style,refactor,perf,test,build,ci,chorecover everything else.revertis a common addition.
A scope in parentheses names the area of the codebase touched, for example fix(parser):. It is optional but pays off in a repo with several areas. Keep the description short and imperative, add rather than added or adds, with no trailing full stop by convention.
The spec says implementations MUST NOT treat any of this as case-sensitive, with one exception: the literal text BREAKING CHANGE must be uppercase. Teams still tend to pick lowercase types and stay consistent. Note that only feat and fix are required by the spec itself; the wider list is convention, and commitlint presets enforce a specific set.
Signalling breaking changes
A breaking change is any commit that forces consumers to change their code. The spec gives two ways to flag it, and you may use both:
- Put a
!immediately before the colon:feat(api)!: drop v1 payment endpoints. - Add a footer beginning with the uppercase token
BREAKING CHANGE:followed by what broke and how to migrate.
feat(api)!: drop v1 payment endpoints
BREAKING CHANGE: clients must move to /v2/payments.
The v1 routes now return 410 Gone.Either signal bumps the major version, whatever the type: a fix! is still a major release. If you use !, the footer may be omitted and the description carries the explanation. A lowercase breaking change: does not count; the spec requires the exact uppercase token.
You can also use the footer alone with no !, which is handy when the break is buried in an otherwise ordinary commit. The spec makes BREAKING-CHANGE with a hyphen an exact synonym, since some tools dislike spaces in footer tokens.
What it buys you
The payoff is automation plus readability:
- Generated changelogs. Tools like
conventional-changelogandrelease-pleasegroup commits by type into a CHANGELOG with nobody writing it by hand. - Automatic version bumps.
semantic-releasereads the commits since the last tag: afeatcuts a minor release, aBREAKING CHANGEcuts a major one. No human picks the number. - Scannable history.
git log --onelinebecomes a list you can read, andgit log --grepnarrowed to a type pulls out just the features or just the fixes. - Review signal. A
refactortouching 30 files is a different review from afeatof the same size.
It also lowers the cost of writing a message. The type prefix is a prompt: you decide up front whether this is a feature, a fix, or housekeeping, which tends to produce a sharper description than a blank line would. The cost is close to zero once it is a habit, and a commit hook keeps the whole team aligned.
Enforcing with commitlint
A convention decays without a check. commitlint validates messages against the spec. Install it and point a config at the conventional preset:
npm i -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.cjs
module.exports = { extends: ['@commitlint/config-conventional'] };Piping a good message and two bad ones through it, real output:
$ echo 'feat(auth): add OTP login' | npx commitlint
(exit 0, no output)
$ echo 'fixed some stuff' | npx commitlint
[FAIL] subject may not be empty [subject-empty]
[FAIL] type may not be empty [type-empty]
$ echo 'feature: add dark mode' | npx commitlint
[FAIL] type must be one of [build, chore, ci, docs, feat, fix,
perf, refactor, revert, style, test] [type-enum]That last one is the classic slip: feature instead of feat. Wire commitlint into a commit-msg Git hook with Husky so a bad message is rejected before it can land:
npm i -D husky
npx husky init
# .husky/commit-msg
npx --no -- commitlint --edit $1Teams that want help writing conforming messages add commitizen, which turns git cz into a guided prompt.
