How to write good commit messages (with examples and tooling)
Commit message rules with good and bad examples, where 50/72 comes from, Conventional Commits, trailers, GitHub squash settings, templates and commitlint.
The KnowWhyy team · · 9 min read
Run git log --oneline on most repositories and you'll find a wall of "fix", "wip", "updates" and "address review comments". Each one made sense to its author for about an hour. Then someone runs git blame on a confusing line, lands on one of those commits, and has to piece the reason together from scratch.
Good commit messages are cheap to write and pay off every time someone reads history. This guide covers the rules, where the famous 50/72 numbers come from, Conventional Commits, trailers, how GitHub squash merges affect all of this, and how to make the habit stick with a commit template and commitlint.
What a commit message is for
A diff shows what changed. Only the message can say why. Chris Beams makes this point in his post on how to write a Git commit message, and it's the whole reason to bother.
The subject line also shows up all over Git itself. Tim Pope's 2008 note on commit messages lists some of the places: git log --pretty=oneline, git rebase --interactive, git shortlog, git format-patch (where it becomes an email subject) and the reflog. The git commit documentation says the text up to the first blank line is treated as the commit title and used throughout Git.
Ask your first question in minutes.
Create a workspace, connect a tool and choose what gets indexed. The free plan includes GitHub, Slack and Notion.
So a commit message has two jobs: a subject that works on its own in a one-line list, and a body that tells a future reader why the change was made.
The rules
A good starting point is Beams's seven rules:
Separate subject from body with a blank line.
Limit the subject line to 50 characters.
Capitalize the subject line.
Do not end the subject line with a period.
Use the imperative mood in the subject line.
Wrap the body at 72 characters.
Use the body to explain what and why, not how.
For the imperative mood, Beams offers a simple test: the subject should complete the sentence "If applied, this commit will ___". Git uses the imperative itself, for example in Merge branch 'myfeature' and Revert "...".
Bad
Why it's bad
Better
fix
Says nothing
Fix crash when cart is empty
Fixed the login bug.
Past tense, period, which bug?
Reject expired tokens on login
Updates
Not a sentence, no subject
Upgrade Postgres driver to 3.2
address review comments
Describes the process, not the change
Squash into the commit it fixes
Refactor UserService and fix date bug and add tests
Three changes in one
Split into three commits
WIP
Fine on a branch, not in main
Squash before merging
If you can't summarize a commit in about 50 characters, that's often a sign it does too much. Beams calls this out directly: struggling to summarize can mean you're committing too many changes at once.
Where 50/72 comes from
The numbers trace back to Tim Pope's 2008 post, prompted by some long, unwrapped messages in the early history of rails.git. His reasoning:
72 for the body.git log doesn't wrap messages. On an 80-column terminal, subtract 4 columns for Git's indent on the left and 4 more for symmetry on the right, and you get 72. He also points to git format-patch, which turns commits into emails, where plain-text netiquette leaves room for reply quoting within 80 columns.
50 for the subject. Pope describes it as a target, not a hard maximum. The subject appears in many truncated, one-line views, so shorter reads better.
The git commit documentation backs the subject guideline: it suggests beginning with a single short line, no more than 50 characters, followed by a blank line. Beams adds that 50 is a rule of thumb and suggests treating 72 as the hard limit for subjects.
Git never wraps your text for you, so set your editor to do it. In Vim, Pope's suggestion is :set textwidth=72.
What to put in the body
Not every commit needs a body. Fix typo in README is complete on its own. When a change needs context, the body should cover:
what was wrong or missing before,
why you chose this fix over the alternatives,
side effects, limitations or follow-ups.
Retry failed card charges once after 7 seconds
The provider rate-limits retries to one per five seconds per card.
Our previous immediate retry was rejected every time and showed up
as duplicate failure emails to customers.
A single delayed retry recovers most transient declines without
hitting the limit. We considered exponential backoff, but invoices
must settle within the same billing run, so one retry is enough.
Fixes #812
Leave out how the code works line by line. The diff shows that. As Beams puts it, focus on why you made the change in the first place.
Conventional Commits
Conventional Commits is a specification that adds a machine-readable prefix to the subject:
fix: is a bug fix and corresponds to a PATCH release in Semantic Versioning.
feat: is a new feature and corresponds to a MINOR release.
A breaking change is marked with ! before the colon (feat(api)!: ...) or a BREAKING CHANGE: footer, and corresponds to a MAJOR release.
Other types are allowed. The spec mentions that @commitlint/config-conventional recommends build, chore, ci, docs, style, refactor, perf and test.
feat(invoices): add CSV export for filtered list
fix(auth): reject expired refresh tokens
refactor(orders): extract pricing rules into PricingPolicy
feat(api)!: remove v1 invoice endpoints
BREAKING CHANGE: clients must use /v2/invoices.
The payoff is tooling. The spec lists generating changelogs, determining version bumps automatically and triggering build and publish processes among its reasons to use it.
One thing to know before you adopt it: the popular commitlint config adds rules the spec doesn't require. According to its README, @commitlint/config-conventional rejects sentence-case subjects and allows headers up to 100 characters. That conflicts with "capitalize the subject" and the 50-character target. Neither is wrong. Pick one convention and write it down, so your team isn't arguing about it in review.
Trailers: Co-authored-by, Fixes and friends
Trailers are Key: value lines at the very end of a message. Git's interpret-trailers docs define them as key-value pairs separated by a colon, in a block preceded by a blank line. Conventional Commits footers follow the same idea.
Co-authored-by. To credit pair or mob programming partners, add one line per person. GitHub's docs say to use an email associated with their GitHub account, or their GitHub-provided no-reply email if they keep theirs private, for the commit to count as their contribution.
Add rate limiting to public search endpoint
Co-authored-by: Maria Lopez <maria@example.com>
Co-authored-by: Sam Chen <sam@example.com>
You can also add trailers from the command line with git commit --trailer, for example git commit --trailer "Co-authored-by: Maria Lopez <maria@example.com>". And git commit -s adds a Signed-off-by trailer, which some projects use for a Developer Certificate of Origin.
Closing keywords. GitHub closes an issue when a commit or PR that references it with a closing keyword is merged into the default branch. The keywords are close, closes, closed, fix, fixes, fixed, resolve, resolves and resolved.
Colons and uppercase both work. Two details from GitHub's docs: in a PR description, the keywords only work when the PR targets the default branch; and a closing keyword in a commit message closes the issue on merge, but doesn't list the PR as linked.
Squash merges change the rules
If your team uses "Squash and merge" on GitHub, the individual commits on a branch disappear. What lands on the default branch is one commit, and its message is whatever the person merging accepts in the merge box.
GitHub lets admins set the default squash message under Settings > General > Pull Requests > Allow squash merging. The default uses the commit's title and message if the PR has one commit, or the PR title and a list of commits if it has two or more. The other options are:
Squash message setting
What lands in history
Default message
One commit: its message. Two or more: PR title and commit list
Pull request title
Just the PR title
Pull request title and commit details
PR title plus the commit list
Pull request title and description
PR title plus the PR body
For most teams, "pull request title and description" gives the most useful history, as long as PR descriptions are good. Write the PR title like a commit subject, and use a pull request description template so the body explains why. The Conventional Commits FAQ endorses a similar approach: in a squash workflow, maintainers can clean up the message at merge time.
With squash merging, it matters much less what people write in "wip" commits on their branch. It matters a lot what the final message says.
Set up a commit template
Git can open your editor with a prompt already filled in. Create a file:
# Subject: imperative, ~50 chars, e.g. "Reject expired tokens on login"
# Why is this change needed? What was wrong before?
# What alternatives did you consider?
# Trailers, e.g.:
# Fixes #123
# Co-authored-by: Name <email@example.com>
Then point Git at it:
git config --global commit.template ~/.gitmessage
# or, for one repository, with a template checked into the repo:
git config commit.template .gitmessage
The commit.template setting specifies the file to use for new commit messages. A few behaviors from the git commit docs: if you exit the editor without editing the message, the commit is aborted; the template has no effect when you pass a message with -m; and by default, lines starting with # are stripped when you edit the message, so the prompts don't end up in history.
Enforce it with commitlint
A template nudges. commitlint enforces. Install it with the conventional config:
A commit like foo: this will fail is now rejected because foo isn't an allowed type. Local hooks are easy to skip, so the guide also recommends checking in CI. Its GitHub Actions example lints every commit in a pull request:
That step needs actions/checkout with fetch-depth: 0 so the full history is available. If you squash-merge, consider linting the PR title instead of every branch commit, since the title becomes the subject.
Where KnowWhyy fits
Good commit messages make history readable. Finding the right commit still takes knowing what to search for. KnowWhyy indexes the commits, pull requests and issues in the GitHub repositories you choose (not the source code itself), together with your Slack and Notion. You ask a question like "why do we retry card charges only once?" in the web app and get an answer that links to the commits and threads behind it, or a clear "the sources don't say". See GitHub history search, or start free. For tracing a single line by hand, see why was this code written?.
The short version
Write a subject that stands alone (imperative, about 50 characters) and a body that explains why.
50/72 comes from Tim Pope's 2008 post: 72 fits git log's indent in an 80-column terminal; 50 is a target for one-line views.
Conventional Commits adds type(scope): prefixes that drive changelogs and version bumps; commitlint's default config adds stricter rules than the spec.
Use trailers for Co-authored-by and closing keywords like Fixes #123.
If you squash-merge, set GitHub's default message to "pull request title and description" and write good PR descriptions.
Make it a habit with commit.template, and enforce it with commitlint in a hook and in CI.