RelicRELIC
Shipping better code

Conventional Commits, explained accurately

Conventional Commits is a small, strict format for writing commit messages that both people and machines can read. Get the shape right and your changelog, your version numbers and a greppable project history come almost for free. This is the spec, accurately, plus how teams actually adopt it without the process turning sour.

Jordan Gibbs July 10, 2026 9 min read

Open the git log of most projects and you find a wall of “fix stuff”, “wip”, and “update again”. It is history in name only. Conventional Commits is a convention for the first line of a commit message that fixes this by giving each commit a machine-readable prefix. The payoff goes past tidiness. Once a machine can read your commits, it can generate your changelog, decide your next version number, and let you filter a year of history down to every breaking change in one command.

The specification is currently at version 1.0.0 and it is short enough to read in ten minutes. What follows is the format itself, then the parts people get wrong, then the honest tradeoffs around adopting it on a real team.

The format

Every conventional commit follows one structure. The type and description are required, the rest are optional:

structure
type(scope)!: description

[optional body]

[optional footer(s)]

Read left to right. The type is a noun like feat or fix. The scope in parentheses is optional and names the part of the codebase you touched, such as (parser) or (api). The ! is optional and flags a breaking change. Then a colon, a single space, and a short description in the imperative mood. Here is the everyday case with no ceremony:

a normal commit
feat(auth): add passkey sign-in

Users can now register a passkey and sign in without a password.
Falls back to the existing email link flow on unsupported browsers.

Closes #482

The two types the spec actually defines

This is the part most explainers blur, so it is worth stating precisely. The Conventional Commits specification defines the meaning of exactly two types, and it ties them directly to semantic versioning:

  • fix: a commit that patches a bug. It correlates with a PATCH release (the third number, as in 1.4.2).
  • feat: a commit that introduces a new feature. It correlates with a MINOR release (the middle number, as in 1.5.0).

That is the whole contract the spec makes about release semantics. A tool reading your history can bump the version correctly from those two types alone, plus one more rule for breaking changes.

The causal chain is the point: fix means PATCH, feat means MINOR, and a breaking change means MAJOR. If those words are unfamiliar, our companion piece on semantic versioning explains what each number promises.

Breaking changes drive the MAJOR bump

A breaking change is any commit that would force a consumer of your code to change theirs. The spec gives you two ways to mark it, and either one on its own is sufficient. The first is an exclamation mark right before the colon:

breaking change, the ! form
feat(api)!: return ISO timestamps instead of unix seconds

The second is a footer that starts with the exact token BREAKING CHANGE: in uppercase, followed by a description of what broke and how to migrate. This form gives you room to explain:

breaking change, the footer form
refactor(config)!: rename timeout option to timeoutMs

BREAKING CHANGE: the "timeout" option is gone. Rename it to
"timeoutMs" and multiply your old value by 1000, since the unit
is now milliseconds rather than seconds.

Note that a breaking change can ride on any type at all. A fix! or a refactor! that changes a public contract still forces a MAJOR bump. The signal is the ! or the footer, and the type it rides on is irrelevant.

The extended types everyone actually uses

Beyond feat and fix, the spec permits any type you want and stays silent on their meaning. In practice a small, near-universal vocabulary has settled, mostly inherited from the Angular commit convention. These types leave the version alone and earn their keep by making history far easier to scan:

  • docs: documentation only, no code change.
  • refactor: a code change that neither fixes a bug nor adds a feature.
  • test: adding or correcting tests.
  • chore: maintenance that does not touch src or tests, like bumping a dependency.
  • build: changes to the build system or external dependencies.
  • ci: changes to CI configuration and scripts.
  • perf: a change that improves performance.
  • style: formatting, whitespace, semicolons, nothing that affects meaning.

The advice buried in that list: keep the set small. A team that invents twenty types spends its energy debating whether a change is a chore or a build instead of shipping. Nine types cover almost everything.

Body and footer rules

The body is free-form prose. It begins one blank line after the description and explains the why, the context a reviewer or a future maintainer needs that does not fit in a one-line summary. The spec asks only that a blank line separate the description from the body.

Footers come last, each on its own line, in a token: value shape borrowed from git trailers, such as Reviewed-by: Kim or Refs: #99. The one footer with defined meaning is BREAKING CHANGE:. As a convenience the spec allows BREAKING-CHANGE with a hyphen as a synonym, since a hyphen is a valid token character and a space is not.

Why bother: the three payoffs

The format earns its keep through what it unlocks, and that comes down to three concrete wins.

Automated changelogs and releases

Tools like semantic-release and release-please read the commit types since the last tag, compute the correct next version, generate a categorised changelog, tag the release, and publish it, with no human deciding the number. Your commit messages become the release notes.

A greppable history

A structured prefix means you can query your own history. Every breaking change since a tag, every performance commit, every fix that touched the parser, all one command away:

filtering history
# every feature and fix since the last release tag
git log v2.3.0..HEAD --oneline --grep="^feat\|^fix"

# every breaking change, ever
git log --grep="BREAKING CHANGE"

A shared vocabulary

The subtle win is human. When a whole team writes fix: and feat:, a reviewer knows the stakes of a diff before reading a line of it, and a newcomer can read the log as a narrative rather than noise.

Adopting it on a real team

The convention only pays off if the log is consistent, and consistency across a team comes from tooling that checks messages before they land. A practical rollout looks like this:

  • Add commitlint with the config-conventional preset and wire it to a commit-msg hook via husky, plus a CI check. A malformed message fails fast, locally, before it ever reaches the shared history.
  • Agree the type list in one short document and resist adding to it. Fewer types, fewer arguments.
  • Let a tool decide versions. Once the log is clean, hand the version bump and changelog to semantic-release so no one relitigates whether a change was minor or patch.

A minimal commitlint.config.js is all the enforcement most teams need:

commitlint.config.js
module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    // keep the subject short and lowercase; tune to taste
    "subject-max-length": [2, "always", 72],
  },
};

The honest criticisms

Conventional Commits is not free, and pretending otherwise is how it gets resented. Two objections come up repeatedly and both are fair.

The first is friction. Prefixing every commit feels like bureaucracy until the automation pays it back, and if you never turn on the automation, you have added ceremony for nothing. Only adopt the format if you intend to use what it unlocks.

The second is squash-merge. Many teams squash each pull request into a single commit on the main branch, which throws away the individual commit messages the convention so carefully structured. The fix is to move the convention up a level: apply it to the pull request title instead. GitHub can lint the PR title and use it verbatim as the squash commit message, so you keep a clean, parseable main-branch history while your working commits stay as messy as you like. A squash workflow is a reason to enforce the convention on the PR, not to skip it. A good pull request template is the natural place to remind people of the format.

A cheat sheet to keep

One block that covers ninety percent of what you will write. Copy it, pin it, and the format stops needing thought:

conventional commits cheat sheet
feat:      a new feature            -> MINOR
fix:       a bug fix                 -> PATCH
docs:      documentation only
refactor:  restructure, no behaviour change
perf:      a performance improvement
test:      add or fix tests
build:     build system / dependencies
ci:        CI configuration
chore:     maintenance, no src/test change
style:     formatting only, no meaning change

feat(scope): optional scope in parentheses
feat!:      the ! flags a breaking change -> MAJOR
            or a "BREAKING CHANGE:" footer does the same

That is the whole convention. The rest is habit, and a good habit here compounds: cleaner history, version numbers that mean something, and a changelog that writes itself. When you have the numbers straight, the next piece is what those version digits actually promise, which is where semantic versioning comes in, and both fit inside the broader habit of shipping a repo that is pleasant to work in, alongside a solid README.

Frequently asked questions

What are the only two commit types defined by the Conventional Commits spec?

The specification itself names exactly two: fix, which correlates with a PATCH release in semantic versioning, and feat, which correlates with a MINOR release. Everything else, docs, chore, refactor, test, ci, build, perf and style, is convention layered on top by teams and tools like Angular. The spec allows any type you like, it just gives feat and fix a defined meaning.

How do you mark a breaking change in a conventional commit?

Two ways, and either is enough. Put a ! right before the colon, as in feat!: drop Node 18 support, or add a footer that begins with BREAKING CHANGE: followed by a description. A breaking change triggers a MAJOR version bump regardless of whether the type is feat, fix or anything else.

Do Conventional Commits require a specific tool?

No. It is a plain-text convention, so you can follow it by hand in any editor. Tools are optional and only add enforcement or automation: commitlint checks messages in CI, commitizen prompts you through the format, and semantic-release reads the history to decide the next version and write the changelog.

Do Conventional Commits work with squash-and-merge?

Yes, but the useful unit shifts. If your team squashes each pull request into a single commit, individual commit messages get discarded, so the convention should live on the PR title instead. GitHub can lint the PR title and use it as the squash commit message, which gives you the same parseable history at the merge level.

Written by
Jordan GibbsFounder, Relic

Jordan Gibbs is the founder of Relic, an end-to-end encrypted, permanent, searchable memory for everything you copy. He writes widely about AI, agents, and practical tooling on Medium, where he is read by tens of thousands, and builds privacy-first software. Here he covers how everyday tools like the clipboard actually work, and how to use them without handing your data to someone else.

MediumGitHubLinkedIn
Part of
Shipping better code
Keep reading
9 min

Semantic versioning (SemVer), explained

The MAJOR.MINOR.PATCH scheme behind almost every package you install. What bumps what, the odd 0.x rules, version ranges in npm and pip, and the one promise SemVer quietly cannot keep.

Read
8 min

A pull request template you can copy today

A Markdown file your host pre-fills into every new PR. Three templates to copy, where the file lives on GitHub and GitLab, and what separates a checklist people use from one they delete.

Read
7 min

A README template that people actually read

The front door of a project, and most visitors decide within the first screen. A copyable README skeleton that leads with the quick start, plus the reasoning and a minimal variant.

Read
developergitconventions