How to handle merge conflicts in Git: a step-by-step guide

Merge conflicts are the moment most juniors panic. The terminal yells, the file looks unreadable, and the instinct is to nuke the branch and start over. Don't. Conflicts are routine, mechanical, and the entire fix takes about three minutes once you know what you're looking at. This guide is the calm version.

What a merge conflict actually is

A merge conflict happens when two branches have changed the same lines in the same file, and Git doesn't know which version to keep. That's it. Nothing is broken, nothing is lost, Git is just asking you to make a choice it can't make on its own.

The trigger is usually one of these:

Git will say something like:

Auto-merging src/api/users.py
CONFLICT (content): Merge conflict in src/api/users.py
Automatic merge failed; fix conflicts and then commit the result.

You're not in trouble. You're in a state Git calls "merging", a paused merge that's waiting for your input.

The conflict markers, explained

Open the conflicting file. You'll see something like:

def get_user(user_id):
<<<<<<< HEAD
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise NotFound("User does not exist")
    return user
=======
    user = db.query(User).filter(User.id == user_id).one_or_none()
    if user is None:
        return None
    return user
>>>>>>> feature/user-cleanup

Three sets of markers, three sections:

Your job: replace the entire chunk (markers and all) with what the final code should look like. That might be:

That's the whole skill. Most conflicts in real codebases are 5-15 lines long and take 30 seconds to read.

The full resolution flow

Step by step, end to end:

# 1. You hit a conflict during pull / merge / rebase.
# 2. See which files conflict:
git status

# Output will list conflicted files under "Unmerged paths":
#   both modified:   src/api/users.py
#   both modified:   src/utils/format.py

Open each conflicting file in your editor. VS Code, JetBrains, and Vim plugins all have UI for this, they highlight the markers and let you click "Accept Current," "Accept Incoming," or "Accept Both." Use the UI if you have one; manually editing the markers is fine too.

# 3. Edit each file to the final correct version.
# 4. After editing, mark each file as resolved by staging it:
git add src/api/users.py
git add src/utils/format.py

# 5. Verify nothing's left unresolved:
git status

# Output should now say "All conflicts fixed but you are still merging."

# 6. Complete the operation:
git commit             # if you were merging
git rebase --continue  # if you were rebasing

If you were rebasing and there are more commits to apply, Git will keep stepping through them, possibly hitting more conflicts. That's normal, repeat the process for each.

Run the tests before you celebrate

This is the step every junior skips. Resolving the markers means the file compiles. It does not mean your code is correct.

You can produce a syntactically valid file from a conflict that's still semantically broken, for example, by accidentally deleting a function call that one branch added but you forgot. Always run the test suite (or at minimum the local app) after resolving conflicts and before pushing.

npm test          # JS
pytest            # Python
go test ./...     # Go

If anything was silently lost in the merge, tests catch it. Push without testing and you've shipped a regression.

Practice the full Git workflow on real codebases

InternQuest's missions drop you into pre-built repositories with realistic conflicts and convention requirements. Branch, fix, commit, push, and resolve. Free.

Try a Git mission →

Merge vs rebase: which causes conflicts where

Both can produce conflicts. The difference is when and how:

Merge (git merge main)

Combines two branches into one new commit. If conflicts occur, they happen once, in a single merge commit. You resolve them, commit, done. Easy to reason about. Adds a "merge commit" to your history.

Rebase (git rebase main)

Replays each of your branch's commits one-by-one on top of main. If commit #1 conflicts, you resolve it. Then commit #2 might also conflict, sometimes with the same code, because you're applying different historical states. You can hit conflicts multiple times in a row.

Rebase makes prettier history (linear, no merge commits) but takes longer with many commits. For a feature branch with 8 commits and significant divergence from main, you might hit 3-4 separate conflict rounds during a rebase that would have been one with merge.

Practical rule: if you're a junior on a small team, use git merge main when syncing your feature branch. Save rebasing for when you're more comfortable. Most teams accept either.

How to abort if you're in over your head

You started a merge or rebase, you're staring at 47 conflicts in 12 files, and the panic is rising. There's an exit:

# If you were merging:
git merge --abort

# If you were rebasing:
git rebase --abort

# If you cherry-picked:
git cherry-pick --abort

This puts you back to exactly where you were before. Nothing is lost. Now you can think about it, ask for help, or break the merge into smaller pieces (e.g., merge in chunks instead of all at once).

Knowing the abort exists is half of feeling confident with conflicts. You can always back out.

Strategies that prevent conflicts in the first place

Most conflicts are avoidable with three habits:

  1. Sync often. Pull from main into your feature branch every day or two. Small conflicts resolved often = no big conflict storm at the end. The big-bang merge after two weeks of divergence is where suffering lives.
  2. Keep PRs small. A 100-line PR rarely has conflicts. A 1,500-line PR has them constantly. Splitting work into smaller branches is a conflict-prevention strategy as much as a review-quality one.
  3. Communicate when working in the same area. If you and a teammate are both editing users.py, mention it. Coordinate so you're not both rewriting the same function in different directions.

Less-common conflict types you'll eventually meet

Binary file conflicts

Git can't merge images, PDFs, or compiled binaries. You have to pick one version with git checkout --ours path/to/file or --theirs. The resolution is faster but less flexible, you can't combine.

Rename conflicts

You renamed a file; someone else edited it. Git often handles this gracefully but sometimes asks you to confirm. git status will explain.

Deletion conflicts

You modified a file; someone else deleted it. Git asks: do you want to keep your modifications (re-create the file) or accept the deletion (lose your changes)? Make sure you understand which before deciding.

Lockfile chaos

Both branches updated package-lock.json or poetry.lock. Don't try to manually merge these, they're machine-generated. Instead: keep one side, delete the other, and regenerate:

git checkout --theirs package-lock.json
rm package-lock.json
npm install   # regenerates lock from package.json

Tools that make conflicts less painful

The mindset shift

Merge conflicts feel like a Git error. They're not, they're a question. Git is asking you the only thing it can't figure out alone: "two people changed this; what's the right answer?"

Once that frame clicks, conflicts become routine. You'll resolve dozens during a real internship without anyone making a big deal of it. The first one is scary; the hundredth takes 90 seconds.

And if you ever feel stuck, remember: git merge --abort exists. You always have a way back.

Practice Git mechanics on real broken codebases

InternQuest's missions include realistic Git scenarios, branching, conventional commits, and PR workflows graded by an automated reviewer. Practice the muscle, free.

Try a Git mission →