Merge Conflicts Are Not Git's Fault. Here Is What Actually Causes Them.
by Arif Ikhsanudin, Backend Developer
Blaming the Tool Misses the Point
Every developer has seen this: two branches merge and produce conflicts that take an hour to resolve. Someone mutters "Git is being difficult." Git is not being difficult. Git found two sets of instructions that contradict each other and asked a human to decide. That's exactly what it should do.
The frustration at merge conflicts is misdirected. The conflict didn't happen because of the merge. It happened because of how two pieces of parallel work were organized. Understanding the mechanics of conflict detection reveals the upstream causes — and the upstream causes are fixable.
How Git Detects Conflicts
Git uses a three-way merge algorithm. When merging branch B into branch A, Git identifies three points:
- The merge base — the most recent common ancestor commit of A and B
- The tip of A — the current state of the branch you're merging into
- The tip of B — the current state of the branch you're merging from
For each line in each file, Git evaluates:
- If only A changed the line (relative to the merge base): accept A's version
- If only B changed the line: accept B's version
- If neither changed the line: keep the original
- If both changed the same line differently: conflict
Merge base (common ancestor):
def calculate_tax(amount):
return amount * 0.1
Branch A changed it to:
def calculate_tax(amount, rate=0.1):
return amount * rate
Branch B changed it to:
def calculate_tax(amount):
return round(amount * 0.1, 2)
Result: CONFLICT — both branches modified the same lines differently
Git cannot decide whether the correct result is:
return round(amount * rate, 2)(combines both changes)- A's version alone
- B's version alone
That's a semantic question about intent, not a syntactic question Git can answer.
What Actually Causes Conflicts
Long-lived branches are the primary cause. The longer a branch lives without integrating with main, the more likely it is that someone else will touch the same code. The merge base gets older, the diffs get larger, and the chance of overlapping changes grows. A branch open for three weeks has dramatically more conflict risk than a branch open for three days.
Multiple people editing the same file without coordination creates conflicts even on short-lived branches. If your team has three developers all actively changing PaymentService.java, the chance of a conflict on the next merge approaches certainty.
Reformatting or refactoring without a separate commit causes conflicts that are purely mechanical. Developer A reformats a file (changing indentation, reorganizing imports). Developer B adds a function to the same file. The diff shows a conflict on nearly every line because A's reformatting changed the "before" state that B's changes were based on.
# Bad: one commit that mixes reformatting with new code
git commit -m "Add payment retry and reformat file"
# Good: two separate commits
git commit -m "Reformat payment processor to match style guide"
git commit -m "Add payment retry with exponential backoff"
When the reformat is a separate commit that merges first, B's feature addition is diffed against the already-reformatted baseline, and conflicts disappear.
Changes at the same structural location even without touching the same lines. Git's conflict detection is line-based, but conflicts can arise from proximity. Adding two different functions at the end of a file from two branches may conflict if they were both added "at the end" relative to the merge base.
Resolving Conflicts Well
When a conflict does appear, the conflict markers show you all three versions:
<<<<<<< HEAD (branch you're merging into)
def calculate_tax(amount, rate=0.1):
return amount * rate
=======
def calculate_tax(amount):
return round(amount * 0.1, 2)
>>>>>>> feature/tax-rounding
The correct resolution is almost never just picking one side. In this case, the right answer is probably:
def calculate_tax(amount, rate=0.1):
return round(amount * rate, 2)
Which combines both branches' intent. To resolve this correctly, you need to understand what each branch was trying to accomplish — which is why meaningful commit messages and PR descriptions matter even for conflict resolution.
Tools that help:
# Use a three-way merge tool that shows base, ours, theirs simultaneously
git mergetool
# Configure your preferred tool:
git config --global merge.tool vimdiff # or kdiff3, meld, vscode
VS Code's merge editor (git config --global merge.tool vscode) shows all three versions side-by-side and lets you accept changes with checkboxes, which is significantly faster than editing conflict markers manually.
Reducing Conflict Frequency
Integrate frequently. The most effective conflict prevention is merging to main often — ideally daily. Short branches mean small diffs mean rare conflicts.
Communicate about shared files. If two developers are both touching OrderService.java this sprint, they should know about each other and coordinate the order of their merges or temporarily pair on the work.
Separate structural changes from functional changes. Refactors, reformats, and file moves should be distinct commits (or distinct PRs) from functional additions. This makes the diff cleaner and eliminates a large category of spurious conflicts.
Rebase feature branches before merging. A feature branch rebased onto the latest main has its changes applied on top of all the work that would otherwise conflict. Conflicts surface during rebase (where you're the only one resolving them) rather than during merge (where the context is less clear).
git fetch origin
git rebase origin/main
# Resolve any conflicts during rebase, one commit at a time
# Then push and open the PR
Conflicts during rebase are typically smaller and more localized because you're replaying one commit at a time, each with its own context.
The conflict was never Git's fault. It was a predictable outcome of the work structure. Change the structure, and the conflicts largely disappear.