Git Commit Message Best Practices (With Examples)

How to write clear, consistent git commit messages. Covers the Conventional Commits format, the 50/72 rule, imperative mood, and real examples of good and bad commits.

Try it yourself

Use our free Git Commit Linter — no sign-up, runs in your browser.

Open tool →

A good commit message is one of the most useful things you can leave for your future self and your teammates. A bad one - “fix”, “wip”, “update stuff” - tells you nothing six months later when you’re trying to understand why a change was made.

This guide covers the conventions most teams follow, with concrete examples of what good and bad commits look like.

The anatomy of a commit message

A git commit message has two parts:

<subject line>

<optional body>

The subject is a short summary - what changed. The body is where you explain why it changed, any context that isn’t obvious from the diff, and any trade-offs you made. The body is optional but valuable for non-trivial changes.

The 50/72 rule

The most universally followed guideline for subject lines:

  • Aim for 50 characters or fewer. This is the soft limit - it’s where most git UIs start to feel crowded.
  • Stay under 72 characters. Beyond this, many tools (GitHub commit lists, git log, email clients) will truncate or wrap your subject.

Both are recommendations, not hard rules enforced by git itself. But consistently staying within them makes your history much more readable.

✅ fix(auth): redirect to login on token expiry          (49 chars)
✅ feat(api): add pagination to /users endpoint          (51 chars - fine)
⚠️  feat(dashboard): add export to CSV button to the analytics overview panel  (too long)

Use imperative mood

Write the subject as if you’re giving a command to the codebase:

✅ add rate limiting to the API
✅ fix null pointer in user login
✅ remove deprecated payment provider

❌ added rate limiting to the API
❌ fixed null pointer in user login
❌ removed deprecated payment provider

The reason: git itself uses imperative mood for auto-generated messages - “Merge branch”, “Revert commit”. Writing your messages the same way keeps things consistent and reads as “what this commit does to the codebase” rather than “what I did”.

A useful test: your subject should complete the sentence “If applied, this commit will… [your subject].”

No period at the end

The subject line is a title, not a sentence. Don’t end it with a period.

✅ fix: handle empty response from payment API
❌ fix: handle empty response from payment API.

Separate subject and body with a blank line

If you add a body, the blank line between subject and body is not optional - git uses it to distinguish the two. Without it, some tools will treat everything as the subject.

✅ Correct:

feat(auth): add OAuth2 login with Google

Allow users to sign in with their Google account.
Implements the authorization code flow with PKCE.

❌ Incorrect (no blank line):

feat(auth): add OAuth2 login with Google
Allow users to sign in with their Google account.

Wrap body lines at 72 characters

Like the subject, body lines should be kept readable. 72 characters is the convention - it keeps the text comfortable in terminals and side-by-side diffs.

What to write in the body

The body is not a summary of what changed (the diff already shows that). Use it to explain:

  • Why the change was made
  • What problem it solves
  • Trade-offs or alternative approaches you considered
  • Any context that isn’t obvious from reading the code
refactor(db): replace connection pool with singleton

The previous implementation created a new pool per request handler,
which caused connection exhaustion under load. A singleton ensures
the pool is shared across the application lifecycle.

Considered using a context-based approach but it added complexity
without meaningful benefit at our current scale.

Conventional Commits

Conventional Commits is a specification that adds a structured prefix to commit messages. Many teams adopt it because it makes commit history machine-readable - tools can automatically generate changelogs and determine version bumps from it.

The format:

type(scope): description

[optional body]

[optional footer]

Types

TypeWhen to use
featA new feature
fixA bug fix
docsDocumentation changes only
styleFormatting, whitespace - no logic change
refactorCode restructured - no feature or fix
perfPerformance improvement
testAdding or updating tests
choreBuild process, dependency updates, tooling
ciCI/CD configuration changes
revertReverting a previous commit

Scope

The scope is optional and identifies what part of the codebase changed. Keep it short - a module name, layer, or feature area works well.

feat(auth): ...
fix(api): ...
chore(deps): ...
docs(readme): ...

Breaking changes

Signal a breaking change with ! after the type, and explain it in the footer:

feat(config)!: replace JSON config with TOML

JSON config files are no longer supported.

BREAKING CHANGE: rename config.json to config.toml before upgrading.

Examples: good commits

Simple bug fix

fix(api): handle null response from payment gateway

Short, specific, imperative. You know exactly what it does and where.

Feature with scope and body

feat(auth): add JWT refresh token support

Automatically refresh tokens 5 minutes before expiry using a
background timer. Prevents unexpected session termination during
long-running operations.

Dependency update

chore(deps): bump typescript from 5.3 to 5.4

Documentation

docs: document rate limiting behaviour in API reference

Breaking change

feat(config)!: replace JSON config with TOML

JSON config files are no longer supported. Migrate your
config.json to config.toml before upgrading.

BREAKING CHANGE: config format changed from JSON to TOML

Examples: bad commits

Too vague

❌ fix
❌ update
❌ changes
❌ fixed stuff
❌ wip

These tell you nothing. Six months later you’ll have no idea what was changed or why.

Referencing a file instead of a change

❌ update main.js
❌ fix index.html
❌ edit config

The diff already shows what file changed. The commit message should explain what changed and why.

Past tense

❌ fixed the login bug
❌ added pagination
❌ removed unused imports

Use imperative: fix the login bug, add pagination, remove unused imports.

Trailing period

❌ fix: handle null user in login response.

No blank line before body

❌ feat: add dark mode
Users can now toggle dark mode in settings.

Setting up commitlint

If you want to enforce commit conventions automatically in your repo, commitlint is the standard tool. Pair it with husky to run it as a git hook:

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
// commitlint.config.js
export default { extends: ['@commitlint/config-conventional'] };
# Add the commit-msg hook
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit $1'

Now every commit is checked against the Conventional Commits spec before it lands in your history.


Check your commits before you push

Use our Git Commit Linter to check your messages interactively. Paste a commit message and get a score with specific feedback on length, format, imperative mood, and clarity - no install required.

Ready to try it?

Free, client-side Git Commit Linter — nothing sent to a server.

Open Git Commit Linter →