DEV Community

Cover image for Stop Writing Bad Commit Messages — The Complete Conventional Commits Guide
Ovi ren
Ovi ren

Posted on

Stop Writing Bad Commit Messages — The Complete Conventional Commits Guide

I'll be honest — I used to write terrible commit messages.

fixed stuff. update. ok now it works. Sound familiar?

It wasn't until I came back to one of my own projects after a few months and had absolutely no idea what I'd done or why, that I realized I was basically leaving myself (and anyone else) completely in the dark.

So I went deep on Conventional Commits, built this guide as a reference for myself, and now I'm sharing it here. It's the most complete version I could put together. Bookmark it, share it with your team — and let's never write a useless commit message again.


Why Does This Even Matter?

You might be thinking — it's just a commit message, who cares?

Here's the thing. Three months from now, you will care. A new teammate will care. The person reviewing your PR will care.

Bad commit history looks like this:

fixed stuff
update
wip
changes
ok now it works
final final FINAL
Enter fullscreen mode Exit fullscreen mode

That tells you nothing. Good commit history looks like a story — you can scroll through it and actually understand what happened, when, and why.

On top of that, good commits unlock real automation:

  • Automatic changelog generation
  • Automatic semantic versioning
  • CI/CD tools that understand your history
  • git bisect that actually helps you find bugs

This is also the standard used by Angular, Vue, Electron, ESLint, and thousands of other major open source projects. Once you get it, you won't go back.


The Format

It's simpler than it looks:

<type>(<scope>): <description>

[optional body]

[optional footer(s)]
Enter fullscreen mode Exit fullscreen mode
Part Required Description
type Category of change
scope Area of the codebase affected
description Short summary of the change
body What changed and why
footer Breaking changes, issue refs, co-authors

A couple of rules to remember:

  • Always leave a blank line between the description and body
  • Always leave a blank line between the body and footers
  • The ! symbol after type/scope means it's a breaking change

Types — What Goes Where

This is the part most people get wrong. Let me break down every type so there's no guessing.


feat — New Feature

Something new that users or API consumers can actually see or use.

feat: add dark mode toggle
feat(auth): add OAuth2 login support
feat(api): add pagination to /users endpoint
Enter fullscreen mode Exit fullscreen mode

MINOR version bump (1.0.01.1.0)


fix — Bug Fix

Something was broken. Now it isn't.

fix: resolve crash on empty search input
fix(cart): correct total price calculation
fix(mobile): fix nav menu not closing on tap
Enter fullscreen mode Exit fullscreen mode

PATCH version bump (1.0.01.0.1)


chore — Maintenance

All the boring-but-necessary stuff that doesn't touch production behavior or tests.

chore: update dependencies
chore: remove unused imports
chore: clean up .gitignore
chore(deps): bump lodash from 4.17.20 to 4.17.21
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


docs — Documentation

README updates, inline comments, JSDoc, wikis, changelogs — anything that's purely documentation.

docs: add installation guide to README
docs(api): document rate limiting behavior
docs: fix broken links in CONTRIBUTING.md
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


style — Code Formatting

Whitespace, semicolons, import ordering — the stuff your formatter handles. Nothing that changes how the code actually runs.

style: format files with prettier
style(button): fix inconsistent spacing
style: remove trailing whitespace
Enter fullscreen mode Exit fullscreen mode

⚠️ Don't confuse this with CSS or visual UI changes. Use feat or fix for those.

→ No version bump.


refactor — Code Restructuring

You rewrote something internally — cleaner, simpler, better organized — but the behavior didn't change and no bug was fixed.

refactor: extract validation into separate module
refactor(auth): simplify token refresh logic
refactor: replace forEach with map in data transform
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


test — Tests

Adding, fixing, or updating tests. No changes to production code.

test: add unit tests for useAnimeList hook
test(api): add integration tests for /search endpoint
test: fix flaky timeout in auth tests
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


perf — Performance

Made something faster without changing what it does.

perf: debounce search input to reduce API calls
perf(images): add lazy loading to anime cards
perf: memoize expensive genre filter computation
Enter fullscreen mode Exit fullscreen mode

→ PATCH version bump in some configs.


ci — CI/CD Config

GitHub Actions, CircleCI, Jenkins workflows — anything that touches your pipeline config.

ci: add automated deployment to Vercel
ci: fix failing lint step in GitHub Actions
ci(docker): update Node.js base image to 20-alpine
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


build — Build System

Webpack, Vite, tsconfig, package.json — anything related to how your project gets built.

build: migrate from CRA to Vite
build: add path aliases to tsconfig
build(deps): add recharts as dependency
Enter fullscreen mode Exit fullscreen mode

→ No version bump.


revert — Reverting a Commit

Something went wrong. You're going back.

revert: revert "feat: add experimental offline mode"
Enter fullscreen mode Exit fullscreen mode

Always mention the hash in the body so others know exactly what was undone:

revert: revert "feat: add experimental offline mode"

Reverts commit a1b2c3d.
Caused a regression in the login flow on Safari.
Enter fullscreen mode Exit fullscreen mode

Extended Types

These aren't universal, but you'll see them in larger codebases:

Type When to use
security Patching a security vulnerability
hotfix Urgent production fix
i18n Internationalization changes
a11y Accessibility improvements
infra Infrastructure changes (Terraform, Docker, K8s)
init Initial project setup
wip Work in progress — should never stay on main

Scopes — Be Specific About What You Touched

Scopes are optional, but once your codebase grows past a certain point, they become really valuable. They tell you where the change happened at a glance.

feat(auth): add JWT refresh logic
fix(navbar): resolve dropdown z-index issue
chore(deps): update react to 18.3.0
Enter fullscreen mode Exit fullscreen mode

A few rules:

  • Lowercase only
  • One or two words max
  • Pick one name and stick to itauth and authentication being used interchangeably is confusing

Common patterns:

# By feature
feat(auth), feat(chat), feat(search), feat(dashboard)

# By layer
fix(api), fix(ui), fix(db), fix(hooks)

# By platform
fix(mobile), fix(ios), fix(web)

# By component
style(AnimeCard), refactor(useAnimeList), test(anilist)
Enter fullscreen mode Exit fullscreen mode

Writing a Good Description

This is where most people slip up. The description is the short summary after the colon — and there are a few rules that matter more than you'd think.

✅ feat: add login page
❌ feat: Added login page
❌ feat: adding login page.
❌ feat: Add Login Page
Enter fullscreen mode Exit fullscreen mode
  • Imperative mood — write it like a command. add, not added or adding
  • Lowercase the first word (unless it's a proper noun)
  • No period at the end
  • Under 72 characters — so it doesn't get cut off in terminals or GitHub
  • Be specificfix episode count not updating after progress change is infinitely more useful than fix bug

The Commit Body — Use It More Than You Do

Most people skip the body entirely. That's a mistake.

The body is where you explain why you made the change — and that's the part that's actually hard to figure out later. The diff already shows what changed.

Here's a good example:

feat(chat): wire recommendations through AniList API

Previously recommendations were pulled from a static local array
of 20 hardcoded titles. Every user got the same suggestions
regardless of what genres they asked for.

Now recommendations query the AniList GraphQL API directly,
filtering by genre and sorting by popularity. Results are
dynamic and always current.

Note: adds a 300-500ms delay on recommendation requests.
Debounced to avoid hammering the endpoint.
Enter fullscreen mode Exit fullscreen mode

Future you will thank present you for this.


Footers — Small Details That Matter

Closes #42
Fixes #17
BREAKING CHANGE: description of what broke
Co-authored-by: Jane Doe <jane@example.com>
Enter fullscreen mode Exit fullscreen mode

Closes #42 is worth the two seconds it takes to type — GitHub and GitLab will automatically close the linked issue when the commit lands on your default branch. It creates a clean paper trail between the code and the original task or bug report.


Breaking Changes

Two ways to flag them:

Method 1 — The ! symbol:

feat!: change exportList to return Promise
feat(api)!: remove deprecated v1 endpoints
Enter fullscreen mode Exit fullscreen mode

Method 2 — Footer:

feat(api): update authentication flow

BREAKING CHANGE: API tokens are no longer accepted as query params.
All requests must include the Authorization header instead.
Enter fullscreen mode Exit fullscreen mode

Both together (best option for clarity):

feat(api)!: remove query param authentication

BREAKING CHANGE: API tokens are no longer accepted as query params.
All requests must include the Authorization header instead.
Migration guide: https://docs.example.com/migration/v2
Enter fullscreen mode Exit fullscreen mode

MAJOR version bump (1.0.02.0.0)


Real-World Examples

Everyday commits

feat: add anime search bar to header
fix: resolve infinite scroll triggering on page load
chore: remove console.log statements
style: format components with prettier
docs: add JSDoc to useAnimeList hook
Enter fullscreen mode Exit fullscreen mode

With scope

feat(chat): add quick action buttons for common queries
feat(stats): add genre breakdown pie chart
fix(card): fix episode progress bar overflow on mobile
fix(anilist): handle null description from API gracefully
Enter fullscreen mode Exit fullscreen mode

Full commit with body and footer

fix(anilist): handle rate limiting from AniList API

AniList enforces a 90 requests/minute rate limit. Under heavy
search usage (fast typing), the app was hitting this and showing
blank results with a cryptic 429 error in the console.

Added exponential backoff retry logic with a max of 3 attempts.
Added a user-facing toast notification when retries are exhausted.

Closes #12
Enter fullscreen mode Exit fullscreen mode

Breaking change

feat(export)!: change exportList format from array to object

Previously exportList returned a raw array of Anime objects.
It now returns an AnimeListState object with `animes` and
`lastUpdated` fields, consistent with the internal state shape.

BREAKING CHANGE: importList now expects an AnimeListState object.
Old JSON backups using the array format will no longer import correctly.
Run the migration script at scripts/migrate-backup.js on old files.
Enter fullscreen mode Exit fullscreen mode

What NOT to Do

Vague descriptions

# Bad
fix: fix
feat: update
chore: stuff

# Good
fix: prevent crash when anime list is empty
feat: add episode progress tracking to anime cards
chore: remove unused StatusBadge variant
Enter fullscreen mode Exit fullscreen mode

Wrong type

# Bad — this is a fix, not a style change
style: fix broken layout on mobile

# Bad — this is a feat, not a refactor
refactor: add search history dropdown

# Good
fix: fix broken layout on mobile
feat: add search history dropdown
Enter fullscreen mode Exit fullscreen mode

Too many changes in one commit

# Bad
feat: add dark mode, fix mobile nav, update README

# Good — three separate commits
feat: add dark mode toggle
fix(nav): fix mobile menu not closing on route change
docs: update README with dark mode usage
Enter fullscreen mode Exit fullscreen mode

Uppercase or period

# Bad
feat: Add new search functionality.

# Good
feat: add new search functionality
Enter fullscreen mode Exit fullscreen mode

How This Connects to Versioning

This is where it gets really powerful. Conventional Commits maps directly to Semantic Versioning (MAJOR.MINOR.PATCH):

Commit Type Version Bump Example
feat MINOR — 1.0.01.1.0 New feature added
fix PATCH — 1.0.01.0.1 Bug fixed
perf PATCH — 1.0.01.0.1 Performance improved
BREAKING CHANGE or ! MAJOR — 1.0.02.0.0 API changed
Everything else No bump Docs, chores, style, etc.

Tools like semantic-release and release-please can read your commit history and automatically bump the version, generate a changelog, tag a release, and publish to npm — all without you touching a single config value manually.


Setting Up commitlint

You shouldn't rely on willpower to keep commit messages clean. Automate it.

Install

npm install --save-dev @commitlint/cli @commitlint/config-conventional
Enter fullscreen mode Exit fullscreen mode

Configure

// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
};
Enter fullscreen mode Exit fullscreen mode

Test it

echo "feat: add login page" | npx commitlint
# ✅ passes

echo "added login page" | npx commitlint
# ❌ fails with error
Enter fullscreen mode Exit fullscreen mode

Add Git Hooks with Husky

Now wire it up so it runs automatically on every commit:

npm install --save-dev husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
Enter fullscreen mode Exit fullscreen mode

Done. From this point on, any commit that doesn't follow the convention gets rejected before it even goes through. No more sneaky fix: stuff messages slipping in.


Commitizen — For Teams That Need a Guided Prompt

If your team keeps forgetting the format, commitizen gives everyone an interactive prompt instead:

npm install --save-dev commitizen cz-conventional-changelog
Enter fullscreen mode Exit fullscreen mode

Add to package.json:

{
  "config": {
    "commitizen": {
      "path": "cz-conventional-changelog"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run npx cz instead of git commit and you'll see:

? Select the type of change:
❯ feat:     A new feature
  fix:      A bug fix
  docs:     Documentation only changes
  style:    Changes that do not affect meaning
  refactor: A code change that neither fixes a bug nor adds a feature
  perf:     A code change that improves performance
  test:     Adding missing tests or correcting existing tests
Enter fullscreen mode Exit fullscreen mode

No memorizing required.


Tips for Teams

1. Agree on scopes upfront. Pick a list, write it down somewhere, and stick to it. Consistency matters more than the specific names.

2. One change per commit. If your description needs the word "and", split it into two commits.

3. Commit often, squash before merging. Work in messy WIP commits locally, then clean them up into proper conventional commits before opening a PR.

4. Body = why, not what. The diff shows what changed. The body is for context that won't be obvious six months from now.

5. Reference issues every time. Closes #42 takes two seconds and creates a permanent connection between your code and the original task.

6. Enforce with tooling, not discipline. People forget. Linters don't. Set up commitlint + Husky and take it off your plate.


Quick Cheatsheet

FORMAT
──────────────────────────────────────────────────────────
<type>(<scope>): <description>

[optional body]

[optional footer(s)]


TYPES
──────────────────────────────────────────────────────────
feat        New feature                → MINOR version bump
fix         Bug fix                    → PATCH version bump
chore       Maintenance, no app change → no bump
docs        Documentation only         → no bump
style       Formatting, no logic       → no bump
refactor    Restructure, no behavior   → no bump
test        Tests only                 → no bump
perf        Performance improvement    → PATCH version bump
ci          CI/CD config               → no bump
build       Build system / deps        → no bump
revert      Revert a commit            → depends
feat! / BREAKING CHANGE  Breaking API  → MAJOR version bump


DESCRIPTION RULES
──────────────────────────────────────────────────────────
✅ Imperative mood   → "add feature" not "added feature"
✅ Lowercase start   → "fix bug" not "Fix bug"
✅ No period at end  → "update config" not "update config."
✅ Under 72 chars    → keep it short and specific


FOOTER TOKENS
──────────────────────────────────────────────────────────
Closes #42
Fixes #17
BREAKING CHANGE: description of what broke
Co-authored-by: Name <email>


EXAMPLES
──────────────────────────────────────────────────────────
feat: add anime search by genre
feat(chat): wire recommendations through AniList API
fix: resolve crash on empty input
fix(anilist): handle null description gracefully
chore(deps): bump vite from 5.4.0 to 5.4.19
docs: add setup instructions to README
style: format files with prettier
refactor: extract query strings into constants
test: add unit tests for useAnimeList hook
perf: debounce search input to reduce API calls
ci: add GitHub Actions deploy workflow
build: migrate bundler from webpack to vite
revert: revert "feat: add experimental offline mode"
feat!: rename AnimeStatus values to uppercase
Enter fullscreen mode Exit fullscreen mode

Useful Tools

Changelog Generation

Tool Description
conventional-changelog Generate changelogs from commit history
git-cliff Highly configurable changelog generator
release-please Google's automated release PR tool

Automated Releases

Tool Description
semantic-release Fully automated versioning and publishing
standard-version Versioning using semver and conventional commits

Editor Integrations

Editor Extension
VS Code Conventional Commits
VS Code Commitizen Support
JetBrains Built-in conventional commit support
Vim/Neovim coc-git

Further Reading


If this helped, the full guide lives on GitHub / Read this blog on my portfolio

— Ovi ren

Top comments (0)