Diff two files in Python with difflib

"\n".join(difflib.unified_diff(a, b, "before.txt", "after.txt", lineterm=""))

"\n".join(difflib.unified_diff(a, b, "before.txt", "after.txt", lineterm="")) turns two lists of lines into a unified diff, the format of diff -u and git diff. difflib is in the standard library. Read each file with splitlines(), which drops the line endings, and lineterm="" stops difflib from adding its own.

import difflib

with open("before.txt") as f:
    a = f.read().splitlines()
with open("after.txt") as f:
    b = f.read().splitlines()

print("\n".join(difflib.unified_diff(a, b, "before.txt", "after.txt", lineterm="")))
--- before.txt
+++ after.txt
@@ -1,5 +1,6 @@
 name: api
-image: api:1.4
+image: api:1.5
 replicas: 2
 port: 8080
-log: info
+log: debug
+timeout: 30

That’s Python 3.14. Named "before" and "after", it’s character for character the tool’s Unified diff below.

Why lineterm=“”

Without it, difflib ends the ---, +++ and @@ lines with \n, and the join adds a second one, so a blank line follows each header:

--- before.txt

+++ after.txt

@@ -1,5 +1,6 @@

 name: api

The alternative keeps the endings: a = f.readlines() and sys.stdout.writelines(difflib.unified_diff(a, b, ...)). That breaks on a file whose last line has no newline. With log: info and log: debug last, the two lines run together as -log: info+log: debug. difflib never writes the \ No newline at end of file marker that diff, Git and the tool add.

Not always the shortest diff

SequenceMatcher, which difflib’s diff functions use, finds the longest matching run first and then repeats on the pieces either side, a method the Python docs trace to Ratcliff and Obershelp’s “gestalt pattern matching”. diff, Git and the tool use Myers’ algorithm, which is built to find the fewest changes. Move one line and they disagree:

a = ["x", "y", "z", "y"]
b = ["y", "z", "x", "y"]
print("\n".join(difflib.unified_diff(a, b, "a", "b", lineterm="")))
# --- a
# +++ b
# @@ -1,4 +1,4 @@
# +y
# +z
#  x
#  y
# -z
# -y

That’s 4 changed lines; diff -u, git diff and the tool show 2, -x at the top and +x after z. When the second sequence has 200 items or more, SequenceMatcher also treats any item repeated in more than 1% of it, like blank lines or }, as junk; SequenceMatcher(None, a, b, autojunk=False) turns that off.

ndiff and ratio

difflib.ndiff marks every line and adds ? lines pointing at changed characters. With readlines() input, sys.stdout.writelines(difflib.ndiff(a, b)) prints:

  name: api
- image: api:1.4
?              ^
+ image: api:1.5
?              ^
  replicas: 2
  port: 8080
- log: info
+ log: debug
+ timeout: 30

difflib.SequenceMatcher(None, a, b).ratio() scores similarity from 0 to 1: twice the matching items over the total, 0.5454545454545454 for these lines (2 × 3 / 11). On two strings it compares characters: SequenceMatcher(None, "api:1.4", "api:1.5").ratio() is 0.8571428571428571.

For a side-by-side HTML table, difflib.HtmlDiff().make_file(a, b) returns a whole page.

Try it in the Diff Checker

3 lines added · 2 lines removed

BeforeAfter
1name: api1name: api
2image: api:1.42image: api:1.5
3replicas: 23replicas: 2
4port: 80804port: 8080
5log: info5log: debug
6timeout: 30
Unified diff
--- before
+++ after
@@ -1,5 +1,6 @@
 name: api
-image: api:1.4
+image: api:1.5
 replicas: 2
 port: 8080
-log: info
+log: debug
+timeout: 30

Runs in your browser. Nothing you enter is sent anywhere.