Conventional Commits: How to Write a Better Git Commit Message¶
If you browse the commit history of a long-running project, you will find one of two things. Either a history that reads like a record, something you can actually use to understand why the code is the way it is, or something like this:
I have written commits like that. Most developers have. It happens when you treat the commit message as a formality, when the only audience you are writing for is the CI system that just needs to see something in the message field.
Compare that to this:
feat(datastore): add support for datastore clusters
fix(ssh): prevent IPv6 addresses from being double-wrapped in brackets
refactor(firmware): set firmware configuration during create
chore(deps): bump govmomi from 0.51.0 to 0.52.0
docs: update README with plugin installation instructions
feat(datasource): add virtual machine datasource
Which would you rather read?
The second log follows Conventional Commits, a lightweight specification that gives every commit a predictable, parseable shape. The difference is not talent or effort. It is convention.
This post is my attempt to explain how Conventional Commits work, why each rule exists, and how I use them in practice. A lot of this builds on Chris Beams's foundational piece, How to Write a Git Commit Message. If you have not read it, do that first. What follows extends the principles he outlines into the structured format I have settled on.
The Format¶
A conventional commit message has this shape:
Every element has a job:
- type: what kind of change this is
- scope: what part of the codebase it touches (optional, in parentheses)
- description: a short summary in imperative mood
- body: fuller context on what changed and why (optional)
- footers: metadata, issue references, breaking change notices (optional)
That is the entire grammar. Everything else is about applying it well.
The Seven Rules¶
Here are the seven rules of a great conventional commit message. They are not novel. Most of them apply to any Git commit. Conventional Commits formalizes the structure so that humans and tooling can both act on the information.
- Choose the right type
- Add a scope when it matters
- Write a short, imperative description
- Separate the subject from the body with a blank line
- Keep the subject line to 72 characters or fewer
- Use the body to explain what and why, not how
- Use footers for metadata, references, and breaking changes
Each rule is worth understanding in depth.
1. Choose the right type¶
The type is the first thing anyone reads. It determines whether automated tooling bumps the version, how a changelog entry is filed, and whether a reviewer knows before opening a file whether they are looking at new behavior or a cleanup.
The Conventional Commits specification defines two types that carry semantic versioning weight:
| Type | Meaning | SemVer Impact |
|---|---|---|
feat | A new feature | MINOR bump |
fix | A bug fix | PATCH bump |
Every other type is extended convention, not in the specification, but widely adopted because tooling and humans both benefit from the signal:
| Type | Use |
|---|---|
refactor | Code restructuring with no behavior change |
perf | Performance improvement with no behavior change |
test | Adding or correcting tests |
docs | Documentation only |
build | Build system or external dependency changes |
ci | CI/CD pipeline configuration |
chore | Everything else: toolchain, housekeeping, copyright headers |
revert | Reverting a previous commit |
The distinction between fix and refactor trips people up the most. A refactor is a change that leaves the observable behavior of the system unchanged. If the change also resolves a bug, even incidentally, it is a fix. Be honest with the type. Calling a bug fix a refactor hides it in the changelog and misrepresents the nature of the change to reviewers.
chore is deliberately a catch-all. I use it for dependency bumps (chore(deps)), repository housekeeping, compliance updates, and anything that does not fit cleanly elsewhere. The rule is that a chore commit should never require a version bump. If it does, it is not a chore.
2. Add a scope when it matters¶
The scope narrows the type to a specific component of the codebase. It goes in parentheses, immediately after the type and before the colon:
fix(ssh): prevent IPv6 addresses from being double-wrapped in brackets
feat(datasource): add virtual machine query support
chore(deps): bump govmomi from 0.51.0 to 0.52.0
Scope is optional. A single-component project may never need it. A project with builders, post-processors, data sources, CI workflows, and dependency management benefits from it. The scope answers "where does this live?" before anyone opens a file.
The important thing is consistency. Once you establish that deps means dependency updates and gh means GitHub repository configuration, stick to it across every commit. A scope that means different things in different commits is worse than no scope at all. It trains readers to ignore it.
| Scope | Covers |
|---|---|
deps | Dependency version updates |
ci | GitHub Actions workflows |
gh | GitHub templates, labels, issue config |
| (none) | Cross-cutting changes or no clear scope |
If the right scope is not obvious, omit it. An unscopeable commit is often a commit that is doing too many things.
Document Your Scopes
A good place to record your project's accepted scopes is in CONTRIBUTING.md. List each scope, what it covers, and an example commit. Contributors who have a reference cannot make inconsistent choices by accident.
3. Write a short, imperative description¶
The description is the summary line, everything after type(scope):. Write it in the imperative mood: a verb in command form.
feat(datasource): add virtual machine query support ✓
feat(datasource): added virtual machine query support ✗
feat(datasource): adds virtual machine query support ✗
The test is to read the commit as the completion of a sentence: If applied, this commit will... The first one passes. The others do not.
Git's own generated messages use the imperative mood: Merge branch 'main', Revert "Fix regression". Your commit messages should match.
Keep it short. The type prefix takes up characters. A chore(deps): prefix is already 14 characters, leaving roughly 58 before hitting 72. If you cannot describe the change that concisely, the commit is probably doing too much and should be split.
One exception: routine dependency bumps are fine with a longer first line because the format is mechanical and predictable. chore(deps): bump golang.org/x/crypto from 0.40.0 to 0.45.0 is 66 characters and no one is confused.
4. Separate the subject from the body with a blank line¶
Git always treats the first line as the subject and ignores the rest in commands like git log --oneline, git shortlog, and git bisect. The blank line separating the subject from the body is a convention, not a hard parsing rule, but it is one that nearly every tool, parser, and host (GitHub, GitLab, email-based workflows) depends on.
The blank line matters most for two reasons. First, footers — BREAKING CHANGE:, Closes:, Signed-off-by: — must be separated from the body by a blank line for Git and tooling to parse them correctly. Second, without the separator, many parsers treat the entire message as a single block and cannot reliably distinguish subject from body. The convention exists because it makes the message unambiguous.
fix(ssh): prevent IPv6 addresses from being double-wrapped in brackets
SSH connection strings require addresses in the form [::1]:22.
The blank line is the boundary. It signals where the subject ends and where the explanation begins. Leave it empty. Always.
5. Keep the subject line to 72 characters or fewer¶
72 characters is a long‑standing convention for keeping commit subjects readable across tools. Around this length, many interfaces (including GitHub’s web UI and typical terminals) start truncating or wrapping lines depending on the viewport, and git log --oneline tends to stay readable in a standard terminal window.
For most feat and fix commits, this is easy to satisfy. For chore(deps) bumps with long package names, it sometimes is not. A truncated dependency bump subject is not the end of the world because the pattern is so recognizable that readers parse it without reading the full line.
The commits where the 72-character limit matters are the ones that describe real changes. If you are at 80 characters and still have not finished the description, the description is trying to explain too much. Either split the commit or move the explanation to the body.
6. Use the body to explain what and why, not how¶
This is the most important rule, and the most ignored one.
The code diff shows how a change was made. The commit body is the only place to explain what problem it solves and why this solution was chosen.
Here is the difference in practice.
fix(ssh): prevent IPv6 addresses from being double-wrapped in brackets
SSH connection strings require addresses in the form [::1]:22. When the
address was already bracket-wrapped by an upstream component, the
communicator was wrapping it a second time, producing [[::1]]:22 and
causing all SSH connections to IPv6-addressed targets to fail.
The fix removes the unconditional bracket-wrapping and checks whether
the address is already in bracket form before adding delimiters.
This commit includes what was broken, what behavior was expected, and what the fix achieves. Reading the diff alone, none of that is obvious. Six months from now, when someone is investigating an IPv6 connectivity problem in a different context, this message makes the prior fix findable and understandable.
This commit does not explain any of that:
That is technically a correct conventional commit. It is not a useful one. The description passes the type-check, but the history tells you nothing a reader could not already infer from the diff themselves. The diff shows the code changed. The message should explain why it needed to.
Not every commit needs a body. chore(deps): bump go to 1.24 needs no explanation. A fix that addresses a subtle bug, a feat that introduces a non-obvious design decision, or a refactor that changes the structure of something central all warrant a body.
Ask Yourself
Would a colleague reading only the subject line understand why this change was made? If the answer is no, write a body.
Wrap the body at 72 characters. Terminals are finite and code viewers impose their own widths.
7. Use footers for metadata, references, and breaking changes¶
Footers appear after the body, separated by a blank line. They follow the Git trailer format (Token: value) and serve two purposes.
Issue references. Both GitHub and GitLab recognize a set of closing keywords in commit footers and will automatically close the referenced issue when the commit lands on the default branch. The keywords they support include Closes, Fixes, and Resolves:
feat(datastore): add support for datastore clusters
Builders and post-processors now accept a `datastore_cluster` key as
an alternative to specifying a single `datastore`. When a cluster is
specified, vSphere Storage DRS selects the target datastore within
the cluster based on the active placement policy.
Closes: #574
Not every reference closes an issue. When a commit is part of a larger body of work tracked in an issue or epic but does not complete it, use Ref: instead:
Ref: creates the traceability link without triggering automatic closure. Use it when the work is incremental and the issue should stay open until all related commits have landed.
Breaking changes. The BREAKING CHANGE: footer is the most consequential signal in the Conventional Commits specification. It tells tooling to issue a MAJOR version bump and tells every reader of the changelog that something they depend on has changed in a backward-incompatible way.
There are two ways to signal a breaking change. The ! suffix on the type is the compact form:
The BREAKING CHANGE: footer provides the explanation:
refactor!: update builder identifier to vmware.vsphere
The builder identifier has been updated from `jetbrains.vsphere` to
`vmware.vsphere` to align with the current organization namespace.
BREAKING CHANGE: Builder references to `jetbrains.vsphere`
must be updated to `vmware.vsphere`.
Closes: #629
Use both when the breaking change needs explanation. Use only ! when the subject line is self-explanatory. Never ship a breaking change without at least one of the two.
Breaking Changes
A breaking change is anything that requires consumers to update their configuration, code, or integrations to remain functional. Renamed keys, removed options, changed defaults, altered API shapes. Flag them every time. The alternative is a surprise for every user on the next upgrade.
Putting It Together¶
Here is what the complete format looks like when all the rules are applied to a real change:
feat(datastore): add support for datastore clusters
Builders and post-processors now accept a `datastore_cluster` key as
an alternative to specifying a single `datastore`. When a cluster is
specified, vSphere Storage DRS selects the target datastore within
the cluster based on the active placement policy.
This resolves a common deployment pattern where organizations use
Storage DRS to manage storage placement policies and do not want to
pin image builds to a specific datastore.
Closes: #574
And here is the minimal form for a routine maintenance commit that needs no explanation:
Both are correct. The amount of structure a commit carries should match the complexity of the change, not more, not less.
My Workflow
I use a GitHub pull request or GitLab merge request workflow with squash merge as the only allowed merge strategy. This applies equally to GitHub pull requests and GitLab merge requests. The mechanic is the same on both platforms: the platform uses the pull request or merge request title as the commit message when squashing.
The unit that needs to be a valid conventional commit is not the individual commit on a feature branch. It is the pull request or merge request title. Feature branch history can be as messy as needed. Draft commits, fixups, and rebases are invisible to the main branch history. What matters is that the title is well-formed before merge.
You can validate pull request / merge request titles in CI whenever the pull request or merge request is opened, edited, or synchronized. With this in place, the main branch history is always spec-compliant.
The Decision Tree¶
For most commits, the type decision is straightforward:
- Does this introduce new user-facing behavior? →
feat - Does this fix incorrect existing behavior? →
fix - Does this change code structure without changing behavior? →
refactor - Does this update tests only? →
test - Does this update documentation only? →
docs - Does this update dependencies or build configuration? →
chore(deps)orbuild(deps) - Does this change CI pipeline configuration? →
ci - Does anything above break backward compatibility? → Add
!
Add a scope when the change is localized to a specific component. Write a body when the subject line cannot carry the context. Add footers for issue references and breaking change descriptions.
A commit that is hard to classify with a single type is usually a commit doing more than one thing. The format creates the right kind of friction.
Why Bother¶
Conventional Commits has a recurring cost: the attention required to write a structured message instead of update. The return compounds over time.
A project using Conventional Commits consistently has, essentially for free:
- A complete changelog organized by release and type, generated without manual editing
- Semantic version history that reflects the actual weight of every change
- A
git logthat is useful to read, not just to produce - Release notes that communicate what changed and what it means for users
- A record of every breaking change, with the context that explains why
None of this requires extraordinary effort. It requires only that each commit message follow a format that, once internalized, takes no more time to write than an unstructured one and is considerably more valuable to read.
The payoff shows up the first time someone asks why a particular constraint exists, or how a change regressed, or what actually changed in the last release. The answer is sitting right there in git log.