How to Write Commit Messages That Actually Help Your Team

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 bisect can 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-changelog and release-please generate changelogs directly from commit messages. A well-formatted history produces a changelog automatically; a poorly formatted one produces nothing useful.
  • Code archaeology: git blame shows 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 consumers
  • fix — a bug fix
  • refactor — internal restructuring with no behavior change
  • perf — a change that improves performance
  • test — adding or correcting tests
  • docs — documentation only
  • chore — 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.

Leave a Reply

Your email address will not be published. Required fields are marked *