Introduction
By Day 91 of building professional Git habits, you've mastered branching strategies and learned to write meaningful commit messages. But there's a skill that separates developers who collaborate smoothly from those who create friction: knowing how often to commit. Commit frequency: Git & GitHub best practices explained is about striking the right balance between capturing progress and keeping history clean—a decision that affects code reviews, debugging, and team workflow.
Getting commit frequency wrong leads to either bloated history that obscures what actually changed, or sparse commits that hide important details when something breaks. This article walks you through the decision-making process we've refined across multiple production teams.
Commit each logical step
A logical step is a cohesive unit of work that makes sense on its own. It's not a keystroke, not a line, and not necessarily a day's work. It's something that accomplishes one thing: fixes a specific bug, adds a feature, refactors a module, or updates documentation.
Consider a scenario where you're building a payment processing feature. You might be tempted to commit once at the end of the day with everything bundled together. Instead, break it into logical steps:
- Add the PaymentProcessor interface
- Implement Stripe integration
- Add unit tests for the integration
- Update API endpoint to use the processor
- Add error handling for failed transactions
Each of these is independent enough that another developer can understand it, review it, or revert it without confusion. When you do this, your commit history becomes a narrative of how the feature was built, not a mystery box.
Here's what this looks like in practice:
git log --oneline feature/payment-processing
a3f2b1c Add error handling for failed transactions
d8e9f2a Update API endpoint to use PaymentProcessor
c1b2d3e Add unit tests for Stripe integration
f4e5d6g Implement Stripe integration
e3d4c5f Add PaymentProcessor interfaceWhen a colleague reviews this branch, they can follow the progression. If a bug emerges six months later in transaction handling, you can pinpoint exactly which commit introduced it and understand the reasoning behind it.
The test here is simple: can you write a one-sentence description of the commit that doesn't use "and" or "also"? If you need those words, it's probably multiple logical steps squeezed together.
Working state at every commit when possible
Ideally, every commit should leave the codebase in a working state—tests pass, the app doesn't crash, dependencies resolve. This isn't always possible with long-running features, but it should be the norm you aim for.
Why? Because bisecting breaks becomes feasible. If you need to find which commit introduced a regression, Git bisect will check out commits one by one. If half your commits are broken, bisect is useless. You'll waste time on commits that never worked anyway.
Here's a workflow that maintains working state:
git add src/payment/processor.ts
npm test # Verify tests still pass
git commit -m "Add PaymentProcessor interface"
git add src/payment/stripe.ts src/payment/stripe.test.ts
npm test # Verify tests still pass
git commit -m "Implement Stripe integration"You're being disciplined: test before committing. This takes an extra minute per commit but saves hours debugging later.
On longer-running features, you might need intermediate commits that don't represent a fully shippable state. That's acceptable. But flag them clearly in your branch, or better yet, use a feature branch strategy where you keep the main branch always deployable. We'll circle back to this when discussing squashing.
One team we work with uses a simple check: before pushing, they run their full test suite plus a lint pass. It's a habit that takes 30 seconds and prevents broken code from reaching code review.
Squash noise before sharing
Here's the tension: you want many small logical commits while developing, but you don't want to pollute shared history with "fix typo," "oops forgot test," and "actually this was wrong." That's where squashing comes in.
The rule of thumb: squash before you push to a shared branch or open a pull request. If you're working on a feature branch locally, commit as often as you want. But before sharing, clean it up.
Let's say you've built a feature with this local history:
git log --oneline
a1b2c3d Fix linting error
f4e5d6g Add tests for edge case
c7b8d9e Implement feature
b6c7d8f WIP: rough implementation
a5b6c7e Add feature skeletonThat "WIP" commit and the linting fix aren't useful to reviewers. Squash them into adjacent commits:
git rebase -i HEAD~5
# In the editor:
# pick a5b6c7e Add feature skeleton
# squash b6c7d8f WIP: rough implementation
# squash c7b8d9e Implement feature
# squash f4e5d6g Add tests for edge case
# squash a1b2c3d Fix linting errorAfter rebasing, you have one clean commit. But wait—you wanted logical steps. So actually, group more carefully:
# pick a5b6c7e Add feature skeleton
# squash b6c7d8f WIP: rough implementation
# pick c7b8d9e Implement feature
# squash a1b2c3d Fix linting error
# pick f4e5d6g Add tests for edge caseNow you have three clean commits that tell a story, without the noise.
The key insight: local history is for you. Shared history is for the team. Don't let your debugging process pollute the team's view of how the code evolved.
Push often to back up work
Despite the advice to squash before sharing, you should push frequently—just to your own feature branch, not to main or release branches. This serves two purposes: it backs up your work, and it signals progress to the team.
A common workflow on our teams:
# Local development
git add src/feature.ts
git commit -m "Implement core logic"
# Work continues
git add src/feature.test.ts
git commit -m "WIP: tests in progress"
# Push to your feature branch—not clean yet, but backed up
git push origin feature/my-feature
# More work
git add src/feature.test.ts
git commit -m "Fix test assertions"
# Push again
git push origin feature/my-featureYour local branch is messy. Your origin branch mirrors it. But when you're ready to share (open a pull request), you clean it up with an interactive rebase, then force-push to your own feature branch:
git rebase -i origin/main
# Clean up commits
git push --force-with-lease origin feature/my-featureUsing --force-with-lease instead of --force is crucial—it protects you if someone else has pushed to your branch (unlikely on a solo feature branch, but good practice).
This approach gives you backup without compromising shared history. Your teammates see clean commits when they review your PR, and you've never lost work to a laptop failure.
Common mistakes
Committing too infrequently: Developers sometimes wait until a feature is "done" before committing. This creates massive commits that are hard to review and impossible to bisect. Commit smaller logical steps.
Committing too frequently: On the flip side, some developers commit after every line change. This creates noise that obscures the real changes. Aim for logical steps, not keystroke tracking.
Mixing concerns in a single commit: "Add user authentication and fix database pagination" in one commit. Split them. Even if they touch the same file, they're separate logical steps.
Not squashing before pull requests: Pushing a messy local history to a pull request makes review harder. Clean up your commits first. Your reviewers will thank you.
Force-pushing to main: Never force-push to shared branches like main or develop. Use force-push only on your own feature branches, and use --force-with-lease to protect yourself.
Key takeaways
- Commit at the logical step level: one thing per commit, describable in one sentence without "and."
- Aim for a working state at each commit when possible—tests pass, app runs. This makes bisecting feasible.
- Squash noise and work-in-progress commits before pushing to shared branches. Your local history can be messy; shared history should be clean.
- Push often to your own feature branches for backup, but clean up before opening a pull request.
- Use
--force-with-leasewhen rebasing feature branches, never--forceon shared branches. - Let commit frequency reflect the story of how the code was built, not the randomness of when you felt like hitting the keyboard.






Comments
Loading comments…