You are six months into a codebase and a bug surfaces in production. You open git log and find: “fix”, “WIP”, “update”, “asdf”, “fix2”, “final”, “final_FINAL”. The history tells you nothing. You cannot bisect, you cannot understand intent, and you cannot determine whether any of those commits introduced the regression. This is not a minor inconvenience — it is a compounding maintenance cost that gets worse with every new commit. Writing commit messages that actually work is one of the highest-leverage habits a developer can build.
Why commit messages matter more than most developers think
A commit message is not a description of what you did. It is documentation for a future reader — often yourself — who will need to understand why a change was made under time pressure, without access to your current mental context.
Good commit messages enable three things that “fix” cannot:
- Automated bisection:
git bisectcan find the commit that introduced a bug — but only if each commit represents a single logical change with a clear description. A history of “WIP” commits makes bisection useless. - Meaningful changelogs: tools like
conventional-changelogandrelease-pleasegenerate changelogs directly from commit messages. A well-formatted history produces a changelog automatically; a poorly formatted one produces nothing useful. - Code archaeology:
git blameshows you who changed a line and when. The commit message tells you why. Without the why, you cannot distinguish a deliberate trade-off from an accident — which determines whether you should keep or revert the change.
The structure that works
The most widely adopted convention is the Conventional Commits specification, which structures messages as:
<type>(<scope>): <subject>
[optional body]
[optional footer]
The subject line is the most important part. It is what appears in git log --oneline, in GitHub PR lists, in automated changelogs, and in Slack integrations. Everything else is secondary.
Type
The type declares the category of change. The most commonly used types:
feat— a new feature visible to users or downstream consumersfix— a bug fixrefactor— internal restructuring with no behavior changeperf— a change that improves performancetest— adding or correcting testsdocs— documentation onlychore— maintenance tasks (dependency updates, build config)ci— changes to CI/CD configuration
The type is not just labeling — it is a signal to automated tooling. feat triggers a minor version bump in semantic versioning. fix triggers a patch bump. A commit with BREAKING CHANGE in the footer triggers a major bump. If you use automated releases, the types are not optional.
Scope
The scope is optional but useful in larger codebases. It names the subsystem or module affected: feat(auth), fix(api/payments), refactor(database/migrations). Scopes make log filtering much faster:
git log --oneline --grep="^feat(auth)"
Subject line rules
The subject line has a small number of hard rules that are worth following precisely:
- Use the imperative mood: “add rate limiting” not “added rate limiting” or “adds rate limiting.” The convention follows Git’s own generated messages (“Merge branch…”, “Revert…”).
- Keep it under 72 characters: longer subjects get truncated in most interfaces.
- No period at the end.
- Lowercase after the colon.
- Do not explain what the diff shows — explain what the change means and why it was made.
Writing the body: when and how
Not every commit needs a body. Simple, self-evident changes do not — a subject line of fix(auth): correct token expiry calculation is complete. The body earns its place when the commit involves a non-obvious decision, a trade-off, or context that the diff itself cannot convey.
What belongs in a commit body:
- The problem being solved, if it is not obvious from the subject
- Why this approach was chosen over alternatives
- Known limitations or follow-up work this commit defers
- Links to relevant issues, discussions, or external resources
What does not belong in a commit body:
- A summary of what the diff shows — the diff already shows it
- Praise (“great work on this one”) or self-commentary (“finally fixed this nightmare”)
- TODO items that belong in an issue tracker
A well-written body for a non-trivial commit:
perf(api): replace synchronous file reads with streaming in export endpoint
The export endpoint was loading entire files into memory before writing
to the response stream. Under high concurrency, this caused heap pressure
and occasional OOM errors on instances with less than 2GB of RAM.
Switched to pipeline() from the Node streams API. Memory usage in load
testing dropped from ~1.4GB peak to ~180MB with no measurable latency
change at p99.
Considered using worker_threads to offload the I/O, but the bottleneck
was memory, not CPU — streaming was sufficient.
Closes #847
This body tells you the problem, the root cause, the solution, the measured result, and the alternative that was considered and rejected. Six months from now, this is the difference between understanding a change in thirty seconds and spending an hour reconstructing its rationale.
Practical patterns for teams
Enforce the format with commitlint
commitlint is a linter for commit messages. Add it to your repo with a commit-msg Git hook and it rejects non-conforming commits before they land:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "module.exports = { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
Add the hook via husky:
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
This enforces the convention automatically, without relying on every developer to remember the format.
Squash strategically, not aggressively
Squashing WIP commits before merging is good practice — it prevents “WIP”, “fix”, “fix2” from polluting the main branch history. But squashing too aggressively collapses distinct logical changes into a single commit, losing the granularity that makes git bisect useful.
The right unit of a commit is one logical change — not one file change, and not one feature. A feature that touches authentication, database migrations, and API routing should be three commits, not one and not thirty.
Reference issues consistently
Use the footer for issue references rather than embedding them in the subject line:
fix(payments): handle decimal precision in currency conversion
JavaScript's floating point arithmetic was introducing sub-cent errors
in currency conversion for certain currency pairs. Switched to integer
arithmetic throughout, storing amounts as the smallest currency unit.
Fixes #1203
Refs #1198
Fixes automatically closes the referenced issue on merge in GitHub. Refs links without closing — useful for related issues that the commit does not fully resolve.
Before and after: real examples
// Before
fix stuff
// After
fix(auth): prevent session fixation on password reset
Reset was reusing the existing session ID after credential change.
An attacker who obtained a pre-reset session token could maintain
access after the victim reset their password.
Session is now invalidated and a fresh one created immediately after
successful password change.
Fixes #412
// Before
update dependencies
// After
chore(deps): upgrade express from 4.18.1 to 4.19.2
Includes security patch for CVE-2024-29041 (open redirect via
malformed path). No API changes; existing tests pass without
modification.
Frequently asked questions
Is Conventional Commits the only valid format?
No — but it is the most widely adopted and the one with the best tooling support. If your team has an existing convention that works, consistency within the team matters more than which specific format you use. The core principles (imperative mood, short subject, meaningful body when needed) apply regardless of format.
What about commits to personal projects or experimental branches?
Relax the format on throwaway branches — the overhead is not worth it for work that will be squashed or deleted. Apply the discipline on commits that will survive in main branch history, where they will be read by others (or by you, six months from now).
How do I fix a commit message after the fact?
For the most recent commit: git commit --amend. For older commits: git rebase -i HEAD~N and mark the commit as reword. Avoid rewriting history on commits that have already been pushed to a shared branch — it forces force-pushes and disrupts other developers’ local histories.
Final thoughts
Commit messages are the cheapest form of documentation in software development. They cost seconds to write and can save hours when something breaks in production. The gap between “fix” and a well-structured commit body is not skill — it is habit. Build the habit once, and it runs in the background for the rest of your career.
For more on developer workflows that compound over time, explore our Pro Developer Skills category — including our guides on Git stash workflows and refactoring legacy code safely.
Spencer Blake is a developer and technical writer focused on advanced workflows, AI-driven development, and the tools that actually make a difference in a programmer’s daily routine. He created Tips News to share the kind of knowledge that senior developers use every day but rarely gets taught anywhere. When he’s not writing, he’s probably automating something that shouldn’t be done manually.



