I’ve been writing commit messages for almost two decades. And yet, recently, I found a new reason to think carefully about them.
I was writing an ADR for my team about consistent analytics event naming. As I drafted the convention, I reached for past tense. Events describe things that already happened, so it made sense. That small choice made me pause. If we care enough about consistency in event names to document it formally, shouldn’t we apply the same rigour to our commit messages and PR titles?
Especially since a PR title, when squash-merged, becomes the commit on main. It’s not just a label on a code review. It’s permanent history.
That reflection turned into this guide. It’s opinionated and it works for me, but the most important thing isn’t that you follow this exact format. It’s that your team has one format, documented somewhere, and everyone follows it.
Why This Matters
Good commit messages and PR titles:
- Speed up reviews — reviewers understand intent immediately, without digging through the diff
- Aid debugging —
git logbecomes a searchable changelog instead of a wall of noise - Document decisions — future you (or teammates) understand the why, not just the what
- Enable automation — tools can parse types for changelogs, release notes, and deploys
The Format
I use Conventional Commits, which looks like this:
<type>(<scope>): <subject>
<body>
<footer>
A real example:
feat(auth): add two-factor authentication
Implemented TOTP-based 2FA to reduce account compromise risk.
Users can generate backup codes on initial setup.
Closes #234
The type describes what kind of change it is. I use these:
feat— new featurefix— bug fixrefactor— code change that isn’t a feature or fixperf— performance improvementtest— adding or updating testsdocs— documentationchore— dependency updates, config, toolingci— CI/CD pipeline changesstyle— formatting, linting (non-semantic)
The scope (optional but worth using) is the module or domain affected: auth, api, ui, db, validation, and so on. Pick 8–12 scopes for your project and stick to them.
The subject is the short summary. Keep it under 50 characters, use imperative mood (“add filter” not “added filter”), and don’t put a period at the end.
What Actually Makes a Good Commit Message
The format is the easy part. The hard part is knowing what to say.
I think about it as a 2x2 matrix. Every meaningful commit message should cover four things:
| What | Why | |
|---|---|---|
| High-level | Intent — What does this accomplish? | Context — Why does this code exist? |
| Low-level | Implementation — What did you do? | Justification — Why was this change necessary? |
Intent goes in the subject line:
fix(auth): prevent concurrent token refresh race condition
Context, implementation, and justification go in the body:
fix(auth): prevent concurrent token refresh race condition
When users have multiple tabs open, token refresh requests can race.
Both requests see an expired token, both initiate refresh, and one
ends up with an invalidated token, causing 401 errors in the other tab.
Implemented request deduplication using a Promise stored on auth state.
Subsequent refresh requests await the first one instead of initiating
new refreshes.
Reduces token-related 401 errors by ~95% in multi-tab sessions.
Fixes #892
Not every commit needs all four. A simple refactor might just need intent and a one-liner of context. But when you’re writing a body, ask yourself: am I explaining the why, or just restating what the diff already shows?
✗ Change email validation by adding a regex and checking it multiple times.
✓ Add stricter email validation to reduce bounce rate on password resets.
The code shows the how. The message explains the why.
The Mistakes Worth Avoiding
Vague subjects. The subject should tell someone what changed without reading the diff:
✗ fix bugs
✗ update code
✓ fix(auth): prevent null reference in token refresh
Repeating the type in the subject. The type already says what kind of change it is, the subject should describe what was fixed, not restate it:
✗ fix(date): fix use of update date
✓ fix(date): use updated_at instead of created_at
Mixing unrelated changes. If your subject contains “and”, that’s usually a sign you have two commits:
✗ feat(auth): add 2FA and update dependencies and fix typo in docs
Split it. Easier to revert and understand.
Subjects that are too long. Keep it under 50 characters. Put the rest in the body, not the subject:
✗ feat(api): implement pagination support for user list endpoint with limit and offset params
✓ feat(api): add pagination to user list endpoint
Forgetting to close issues. Link it in the footer:
Fixes #892
GitHub auto-closes the issue on merge. Free changelog entry.
Squash vs. Keep
This comes up in almost every team I’ve worked with, and I’ve seen both camps argue passionately.
My default is squash. Almost always. A single, well-written commit per PR is clean, easy to revert, and forces you to articulate the change clearly. It also keeps git log readable, which is the whole point.
The only time I keep multiple commits is when the feature is large enough that the commits genuinely tell a story worth preserving: each one is a meaningful, self-contained step, and collapsing them into one would lose context that matters when reading the history later. That’s the exception, not the rule.
If you’re unsure: squash. A history full of “fix linting”, “oops”, and “WIP” commits helps nobody.
Before You Submit
My workflow: commit freely while coding. Rough messages, frequent commits. When I’m ready to open a PR, I run:
git rebase -i main
Then I reorder, squash, and reword into a clean narrative. The key operations you’ll use most:
pick— keep as-issquash(ors) — combine with the previous commit, prompting for a new messagefixup(orf) — silently combine, keeping the previous messagereword(orr) — keep the changes, edit just the message
Don’t stress about commit quality while you’re in the flow. Rebase is where you turn your working history into something a reviewer (and future you) will actually appreciate.
Which brings to mind a famous quote:
Write drunk, edit sober — as Hemingway (allegedly) put it. The rebase is the sober part.
PR Titles
PR titles follow the same format as commit messages, because they often end up as the merge commit message. Same rules apply:
feat(auth): add two-factor authentication
fix(api): handle null responses in user endpoint
refactor(state): migrate from Redux to Zustand
Keep it under 60 characters. The body is where context lives, not the title.
One thing worth noting: use imperative mood in the title (“add two-factor authentication”), but past tense in the body (“implemented TOTP-based 2FA…”). The title describes what the PR does; the body narrates what you did.
Here’s the PR body template I use:
## Description
Briefly summarize what was implemented or changed.
## Motivation
Explain why this change was needed. What problem does it solve?
## Implementation Details
Describe how you solved it. Link to relevant code sections if helpful.
- Used X library for Y reason
- Refactored the Z module to improve performance
- Added validation for edge case
## How to test
[Describe how a reviewer can verify this change works as expected]
## Screenshots (if UI-related)
[Add screenshots for visual changes]
## Breaking Changes
[If applicable, describe what changed and migration path]
Tooling
If you want to enforce this automatically, the setup takes about ten minutes:
npm install --save-dev husky commitlint @commitlint/config-conventional
npx husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
Create commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
};
Now every commit gets validated before it’s created. Fail fast, fix fast.
You can also set a global commit template so your editor opens with the format pre-filled:
# Save this as ~/.gitmessage:
# <type>(<scope>): <subject>
#
# <body>
#
# <footer>
git config --global commit.template ~/.gitmessage
For Teams: CONTRIBUTING.md Template
Once you’ve settled on a convention, document it where people will actually find it: in the repo. Here’s a ready-to-paste CONTRIBUTING.md section:
## Commit Messages
We follow [Conventional Commits](https://www.conventionalcommits.org/).
### Format
\`\`\`
<type>(<scope>): <subject>
\`\`\`
### Types
- `feat` — New feature
- `fix` — Bug fix
- `refactor` — Code refactoring
- `perf` — Performance improvement
- `test` — Test additions/changes
- `docs` — Documentation
- `chore` — Maintenance
- `ci` — CI/CD changes
### Scopes
Use one of: `auth`, `api`, `ui`, `db`, `hooks`, `validation`, `state`, `types`
### Rules
- Use imperative mood ("add" not "added")
- Keep subject under 50 characters
- Explain *why* in the body, not *how*
- Link issues: `Closes #123`
### Example
\`\`\`
feat(auth): add two-factor authentication
Implemented TOTP-based 2FA to reduce account compromise risk.
Users can generate backup codes on initial setup.
Closes #234
\`\`\`
## Pull Requests
- Title follows commit format
- Body uses past tense (what you did)
- Link related issues
- Add tests for new features
Adjust the scopes list to match your project. That’s the one section where copy-paste won’t cut it.
Bonus: Changelog Automation
One side benefit of consistent commit messages: you can auto-generate changelogs from your history.
npm install --save-dev conventional-changelog-cli
npx conventional-changelog -p angular -i CHANGELOG.md -s
This reads your commit history and produces a structured changelog grouped by features, fixes, and breaking changes. It only works well if your commits are clean, which is another reason the discipline pays off.
Quick Checklist
Before committing:
- Type is accurate (
feat,fix,refactor, etc.) - Scope reflects what changed (or omitted if global)
- Subject is imperative mood & under 50 chars
- Body (if needed) explains why, not how
- Footer links related issues (
Closes #123) - No
WIP:orDEBUG:left in the message
Before creating a PR:
- Title follows
type(scope): descriptionformat - Description explains the problem & solution
- Linked to relevant issues
- Tests are added or updated
- No breaking changes without a clear migration path
References
- Conventional Commits — The specification this guide is based on. Worth reading in full; it’s short.
- How to Write a Git Commit Message by Chris Beams — The classic post on the topic. The seven rules it lays out are still the best starting point for anyone new to thinking about this.
- Write Better Commits, Build Better Projects — GitHub’s own take on the topic, with a focus on commit sizing and narrative structure.
- Commitlint — The tool that enforces your commit convention automatically. Pairs well with Husky.
Consistency Over Perfection
The thing I kept coming back to while putting this together: none of this matters if the rules change depending on who’s committing. A slightly imperfect convention that everyone follows is worth more than a perfect one that nobody does.
Pick a format. Document it in CONTRIBUTING.md. Set up commitlint if you want guardrails. Then stop thinking about it and get back to writing code.