How to Write Better Git Commit Messages
Conventional Commits, imperative mood, subject vs. body — how to turn your Git history into useful documentation instead of a graveyard of "tweaks".
You open git log on a two-year-old project and find this: "fix", "tweaks", "wip", "trying again", "final version", "final version 2". There is no way to know what changed, when, or why. When you need to understand why a function was rewritten six months ago, you're reading one commit diff at a time, guessing the intent of someone who's no longer on the team.
Bad commit messages are technical debt you pay with interest — in the form of hours lost to git blame.
Why commit messages matter more than most developers think
A commit isn't just a save mechanism. It's timestamped documentation. The Git history is a project's timeline of decisions — and like all documentation, it's useless when written carelessly.
Three situations where a good commit message saves real hours:
Debugging a regression.
git bisectnarrows down the offending commit. If the message says "fix", you still know nothing. If it saysfix(auth): prevent infinite loop when JWT token expires in UTC-3 timezone, you understand the context before even opening the diff.Generating automated changelogs. Tools like
conventional-changelogandsemantic-releaseparse history to produce release notes and calculate semantic version bumps. Without structured messages, there's no automation.Async code review. In distributed teams, the reviewer might be in a different timezone. A well-written commit message reduces back-and-forth questions in the PR and speeds up merge.
If you're still getting comfortable with the Git basics — branches, merge, the staging area — there's a more introductory article here: Git for Beginners: Commits, Branches, and Merge. This article assumes you already use Git and want to use it better.
The Conventional Commits format
Conventional Commits is a specification that defines a minimal structure for commit messages. It's not mandatory, but it's the de facto standard in serious open source projects and in most product teams that care about their history.
The structure is:
<type>(<optional scope>): <short description>
<optional body>
<optional footer>
Main types
| Type | When to use |
|---|---|
feat |
New user-visible functionality |
fix |
Bug fix |
refactor |
Internal change without behavior change |
chore |
Maintenance: CI, dependencies, configs |
docs |
Documentation only |
test |
Adding or fixing tests |
perf |
Performance improvement |
style |
Formatting, whitespace, semicolons — no logic |
Real examples:
feat(payments): add support for recurring Pix payments
fix(auth): fix token refresh when session expires idle
chore(deps): upgrade Node.js from 20 to 22 LTS
refactor(user): extract CPF validation into separate service
Breaking changes
When a change breaks backward compatibility, signal it with ! after the type or with BREAKING CHANGE: in the footer:
feat(api)!: remove /v1/users endpoint in favor of /v2/users
BREAKING CHANGE: The /v1/users endpoint has been removed. Migrate to /v2/users
as documented in docs/migration-v2.md
This is what tools like semantic-release use to automatically bump the major version.
Imperative mood: why write this way
The convention for the description line is imperative mood — as if you're giving the codebase an instruction:
Correct:
add email validationfix race condition in queue workerremove expired A/B test feature flag
Incorrect:
added email validation— past tenseadding email validation— present continuousemail validation was added— passive voice
Why imperative? Because Git itself uses the same style: Merge branch 'feature/x', Revert "feat: add user auth". When your commit sits in history next to Git's own messages, visual consistency matters. And there's a reading that makes sense: this commit, when applied, will add email validation.
Subject vs. body: when to use both
The subject line has a recommended limit of 72 characters. For most commits, that's enough. But there are cases where the why behind the change doesn't fit in one line — and that's exactly what the body is for.
Practical rule: if you'd need to answer "why did you do it this way?" in a code review, put it in the commit body.
fix(billing): round invoice amount to 2 decimal places
JavaScript uses IEEE 754 floating-point, which causes rounding errors
when summing multiple line items (e.g. 0.1 + 0.2 = 0.30000000000000004).
Used Math.round(value * 100) / 100 instead of toFixed(2) because toFixed
has inconsistent behavior across engines for .5 edge cases.
Refs: #1847, MDN Float Precision
This body won't show in git log --oneline, but it will show when someone runs git show <hash> or opens the commit on GitHub. It's decision documentation — the most valuable kind of comment, because it's dated and tied to the change that motivated it.
Separation: always leave a blank line between subject and body. Tools like git log, git rebase, and GitHub depend on this to render correctly.
Scope: use it or not
The scope in feat(scope): is optional and worth using when the project has clearly distinct modules or domains. In a monorepo or a backend with separate bounded contexts, scope saves reading time:
feat(checkout): add shipping calculation by ZIP code
fix(inventory): fix negative count on order reversal
chore(auth): rotate JWT signing key
In small projects or when everything lives in one module, scope can be noise. Use it when it adds real context, not out of obligation.
What not to do: common antipatterns
Diary-style commits: monday, monday 2, work from today — this isn't an activity log, it's a change history. What changed, not when you worked.
Giant commits: a single commit touching fifty files across five different concerns. If you struggle to write a 72-character subject line, the commit is probably too big. Break it into pieces that make sense individually.
WIP as permanent history: using wip as a commit message on a branch that's getting merged directly into main. WIP is acceptable in short-lived feature branches, not in the permanent history.
Lying messages: fix typo that actually refactors half a module. Beyond causing confusion, it breaks git bisect — when you're hunting a bug through binary search, the imprecise message leads you to the wrong commit.
Tools that help
Commitlint: validates that messages follow Conventional Commits in a pre-commit hook. Install in minutes with Husky and stop accepting "final tweaks" messages in your repository.
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
Commitizen: interactive CLI that guides developers through the format instead of typing freehand. Useful for teams that are adopting the convention.
conventional-changelog: automatically generates CHANGELOG.md from history. Worth it once you have commit discipline in place — produces readable changelogs without manual effort.
When reviewing a PR before approving, I use the Text Diff Checker to look through the diff more carefully — especially on large changes where the commit message context should match what's actually in the code.
Frequently asked questions
Do I need Conventional Commits on personal projects?
Not mandatory. But even on solo projects, descriptive messages cost five extra seconds and save hours when you come back to the project three months later. Which would you rather read: fix or fix(parser): fix date parsing for DD/MM format? The benefit exists even without CI or automated changelogs.
What's the ideal commit message length?
The subject line should be at most 72 characters — the limit that guarantees readability in the terminal (git log) and on GitHub without truncation. For the short description, 50 characters is a good target (most interfaces display comfortably up to there). The body has no formal limit, but 3-5 paragraphs is more than enough for any context.
How do I fix the last commit message without creating a new commit?
git commit --amend -m "corrected message"
This rewrites the commit. Only use this if the commit hasn't been pushed to the remote yet — after a push, --amend rewrites history and will create divergence with the remote branch.
Can you enforce Conventional Commits on a team without generating friction?
The lowest-friction approach is to start with lint only in CI pipelines (silent failure on PR, not blocking local dev), then migrate to a pre-commit hook once the team is comfortable. Blocking local commits from the start is the fastest path to resistance — introduce the convention through culture and code review first, then automate.
Commit history is compressed architectural decision-making
Every well-written commit is a product or engineering decision that can be understood months later without interrupting anyone. In large teams, this reduces communication overhead. In open source, it's what allows contributors to understand a project without direct access to the original authors.
The investment is minimal — a few extra seconds per commit. The return compounds every time someone needs to understand the history of a change and finds context instead of silence.
- 01 How LLMs Generate Responses: Tokens, Prediction, and Sampling Explained Tokenization, autoregressive prediction, temperature, and Top-P: the internal mechanics of how language models turn a prompt into text.
- 02 What Is Generative AI: LLMs, Image Models, and Why They Hallucinate LLMs generate text token by token via statistical prediction — no plan, no "knowing." Learn how it works, where it performs, and why it hallucinates.