You just fixed a bug. You type git commit -m "fix" and push. Six months later, a teammate — or your future self — is digging through the commit history trying to understand what changed and why. They find nothing useful. Just “fix.”
This is the most common Git mistake beginners make, and it’s entirely avoidable.
Git commit messages are not just bookkeeping. They are the story of your codebase, written one snapshot at a time. When you learn to write them well, you turn git log from a list of cryptic labels into a narrative that explains every decision your team ever made.
This guide shows you how to write commit messages in natural, human language — and how that maps to GitHub conventions that real teams use every day.
Why Commit Messages Are Written for Humans
Before diving into syntax, understand the purpose: commit messages exist for people, not machines.
When you run git log, you see a timeline of changes. When you run git bisect to find which commit introduced a bug, you read those messages to navigate. When a reviewer opens a pull request, the commit history tells them how you approached the problem, not just what the final diff looks like.
A commit like fix null pointer in data preprocessing takes five seconds to scan. A commit like fix stuff forces you to open the diff and read the code. Multiply that by 200 commits in a sprint, and the difference in productivity becomes real.
The Imperative Mood: Git’s Natural Language
The most important convention in Git commit messages is the imperative mood — writing as if you’re giving a command.
Instead of:
- “Fixed the login bug” ❌
- “Fixes the login bug” ❌
- “I fixed the login bug” ❌
Write:
- “Fix the login bug” ✅
A useful mental trick: your commit message should complete the sentence “If applied, this commit will…”
| Natural sentence | Commit message |
|---|---|
| ”If applied, this commit will add user authentication” | Add user authentication |
| ”If applied, this commit will remove the deprecated API endpoint” | Remove deprecated API endpoint |
| ”If applied, this commit will update the README with setup instructions” | Update README with setup instructions |
| ”If applied, this commit will fix the crash on empty search results” | Fix crash on empty search results |
This convention matches what Git itself uses. Run git merge, git revert, or git cherry-pick and look at the messages Git generates: “Merge branch ‘feature/login’”, “Revert ‘Add user authentication’”. Always imperative.
The Three Parts of a Commit Message
A well-structured commit has up to three sections:
<subject line>
<body>
<footer>
Subject line (required)
One line, 50 characters or fewer, imperative mood, capital first letter, no trailing period.
Fix null pointer exception in user profile loading
GitHub truncates anything beyond 72 characters with an ellipsis, so if you go over 50, stop at 72.
Body (optional)
A blank line after the subject, then a paragraph explaining why this change was made. Wrap lines at 72 characters. Focus on context, not implementation — the code shows what changed; the body explains why.
Fix null pointer exception in user profile loading
The profile endpoint was returning null for users who registered
before the avatar field was added to the database schema.
Added a null check and a fallback to the default avatar URL.
Footer (optional)
Used for metadata: closing issues, marking breaking changes, referencing related commits.
Fix null pointer exception in user profile loading
The profile endpoint was returning null for users who registered
before the avatar field was added to the database schema.
Added a null check and a fallback to the default avatar URL.
Fixes #214
The phrase Fixes #214 on GitHub automatically closes issue #214 when the commit lands on the default branch. GitHub also recognizes Closes, Resolves, and Refs the same way.
Conventional Commits: Adding Structure to Natural Language
Many teams use Conventional Commits, a specification that adds a short type prefix to the subject line. It looks like this:
<type>(<optional scope>): <description>
The type tells readers — and automated tools — what kind of change this is:
| Type | Meaning | Example |
|---|---|---|
feat | New feature | feat: add password reset flow |
fix | Bug fix | fix: correct date parsing on iOS Safari |
docs | Documentation only | docs: add API authentication guide |
style | Formatting, whitespace | style: reformat sidebar component |
refactor | Restructure, no behavior change | refactor: extract validation logic to utils |
test | Add or update tests | test: add unit tests for cart checkout |
chore | Maintenance tasks | chore: update ESLint to v9 |
perf | Performance improvement | perf: cache user sessions in Redis |
build | Build system changes | build: switch from Webpack to Vite |
ci | CI/CD configuration | ci: add GitHub Actions deployment workflow |
The optional scope is a noun in parentheses that narrows context:
feat(auth): add OAuth2 login with GitHub
fix(cart): prevent duplicate items on rapid clicks
docs(api): document rate limiting headers
Breaking changes
If your change breaks backward compatibility, mark it with an exclamation mark or a footer:
feat!: remove support for Node 14
BREAKING CHANGE: Node 14 reached end-of-life. Minimum required version is Node 18.
This notation powers automated changelog generation. Tools like semantic-release read your commits and determine version bumps automatically: fix: → patch (1.0.1), feat: → minor (1.1.0), BREAKING CHANGE → major (2.0.0).
Real Day-to-Day Examples
Here’s what a realistic week of commits looks like for a developer building a feature:
Monday: Starting a feature branch
git checkout -b feature/user-notifications
git commit -m "feat(notifications): scaffold notification model and migration"
git commit -m "feat(notifications): add API endpoint to fetch unread notifications"
Tuesday: Connecting frontend
git commit -m "feat(notifications): add notification bell icon to header"
git commit -m "feat(notifications): display unread count badge on bell"
git commit -m "style(notifications): align badge position with design spec"
Wednesday: Bug found during testing
git commit -m "fix(notifications): prevent duplicate notifications on page refresh"
git commit -m "test(notifications): add integration tests for notification API"
Thursday: Code review feedback
git commit -m "refactor(notifications): extract badge component to ui/NotificationBadge"
git commit -m "docs(notifications): add JSDoc comments to notification service"
Friday: Wrapping up
git commit -m "chore: update changelog for notifications feature"
git commit -m "fix(notifications): resolve edge case when user has zero notifications
The API was returning 404 instead of an empty array when a user had no
notifications. Changed response to return 200 with an empty array.
Fixes #312"
Each of these commits is a single logical change. If something breaks, you can pinpoint exactly which commit introduced it. If you need to revert one step, you can do so without touching unrelated work.
What Goes Wrong Without Good Commits
Real commit logs from codebases that skip these conventions often look like this:
wip
more stuff
fix
asdfsdf
working now
final
final v2
FINAL FINAL
The damage is not obvious until you need to:
- Debug a regression: You can’t use
git bisecteffectively when commits mix unrelated changes - Generate a changelog: Automated tools produce garbage output
- Onboard a teammate: They have no way to understand the project’s evolution
- Roll back safely: Reverting “final v2” might undo three unrelated features at once
Practical Rules to Start With Today
If Conventional Commits feels like too much to adopt immediately, start with these three rules:
1. One logical change per commit. Don’t bundle a bug fix and a new feature into the same commit. If your diff touches three unrelated files, break it into three commits.
2. Answer “what” in the subject, “why” in the body. The diff already shows what changed. Use the body to explain the decision behind it.
3. Write for the person reading in 6 months. That person might be you. Would the message make sense without opening the diff?
AI Tools That Help You Write Better Commits
Writing good commit messages is a learnable skill, but AI tooling can accelerate the process while you build the habit:
- AICommits — CLI that reads your staged diff and generates a commit message. Supports plain, conventional commits, and gitmoji formats.
- AI-Commit — Git hook integration; auto-generates messages on every staged commit. Supports local models via Ollama for privacy.
- GitKraken AI — GUI-based tool that writes commit messages, PR descriptions, and even resolves merge conflicts with AI context.
- Windsurf AI Commit Messages — IDE-integrated, one-click commit message generation from staged changes.
- Claude Code — Running
git diff --stagedand asking Claude to draft a conventional commit message is a fast, flexible option if you already use it in your workflow.
These tools are a scaffold, not a crutch. Read and edit the suggestions before accepting them — the goal is to develop your own judgment about what makes a message useful.
How GitHub Reads Your Messages
GitHub surfaces commit messages in several places, and knowing this changes how you write them:
- Pull request timeline — Each commit appears with its subject line. A clear conventional commit type immediately tells reviewers what kind of change they’re looking at.
- Issue closing —
Fixes #123in the subject or body closes the issue automatically when merged to the default branch. - Blame view —
git blameshows the subject line next to each line of code. Short, descriptive subjects make the blame view far more useful. - Releases — GitHub’s auto-generated release notes group commits by type if you use conventional commits consistently.
- Search — GitHub indexes commit messages. A well-named commit is searchable months later.
Setting Up Commitlint (Optional, Recommended for Teams)
Once you’re comfortable writing conventional commits manually, you can enforce them automatically with commitlint and Husky:
npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
echo "{ \"extends\": [\"@commitlint/config-conventional\"] }" > commitlint.config.json
Now Git will reject any commit that doesn’t follow the convention before it’s even created. The error message tells you exactly what’s wrong:
⧗ input: fix thing
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
This turns best practices into muscle memory for the entire team.
The Mental Model
Commit messages are a conversation with the future. Every fix:, feat:, and refactor: is a sentence in a story you’re writing collaboratively with your team and your future self.
The conventions aren’t arbitrary rules. The imperative mood matches Git’s own language. The 50-character limit fits in terminal output without wrapping. The type prefixes make changelogs and version bumps automatable. Each convention exists because it solves a real problem that teams encountered at scale.
Start small: pick three rules, apply them for a week, and read your own git log at the end of it. You’ll see the difference immediately.
Sources: Conventional Commits v1.0.0 · How to Write a Git Commit Message — cbeams · Git Commit Message Best Practices — DataCamp · 8 Git Commit Message Best Practices for 2025 — PullNotifier · Commit Like a Pro: A Beginner’s Guide to Conventional Commits — DEV Community · Mastering Conventional Commits — DEV Community · AICommits — GitHub · GitKraken AI Features · Windsurf AI Commit Messages · Commit Messages — The Odin Project