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:

  1. The merge base — the most recent common ancestor commit of A and B
  2. The tip of A — the current state of the branch you're merging into
  3. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why the Best Technical Contractor Is Not Always the One Who Gets Hired

Hiring decisions rarely come down to who is the most technically skilled. They come down to who the client feels most confident about — and those are very different things.

Read more

Why Your Query Is Slow Even Though You Have an Index

Having an index does not guarantee it will be used — indexes can be bypassed due to function application, type mismatches, poor selectivity estimates, or optimizer decisions that are correct given stale statistics.

Read more

Java Optional — What It's For, What It's Not For, and How to Use It Well

Optional is a return type that signals absence explicitly. It's not a null replacement, not a container to store in fields, and not a way to avoid NullPointerException everywhere. Used correctly, it improves API clarity. Used incorrectly, it adds allocation and verbosity without benefit.

Read more

Your Docker Compose File Is Messier Than It Needs to Be

Docker Compose files accumulate complexity as projects grow — hardcoded values, duplicated configuration, missing health checks, and environment-specific hacks that never get cleaned up. A structured approach keeps them maintainable.

Read more