Compare File Contents with Python filecmp: Avoid Shallow Checks and Stale Cache
On this page
A backup and its source have identical sizes and modification times, yet Python says they match even when their bytes differ. The problem may be a metadata shortcut rather than a failed copy.
This beginner-to-intermediate tutorial reproduces shallow-comparison and cache pitfalls in filecmp.cmp(), then builds a read-only file-content comparison command. It distinguishes equality, differences, and failures for local backup checks or automation without deleting or overwriting either input.
1. Method: separate metadata from content
The official filecmp documentation explains the default shallow=True: matching file type, size, and modification time can establish equality without reading content. Explicit shallow=False disables that metadata-only equality shortcut; different sizes can still establish inequality immediately.
This tutorial compares regular files in trusted local directories after writers have stopped:
- Compare bytes without decoding text or ignoring whitespace, line endings, or a BOM. Do not compare permissions, owners, or timestamps.
- Use
shallow=Falseand clear the process-localfilecmpcache before each comparison. - Use exit code 0 for equal content, 1 for different content, and 2 for argument or checking errors.
- Allow symlinks pointing to regular files. This is not a path-containment or hostile-input security tool.
To inspect which configuration lines changed, use the text configuration diff tool. Here the question is byte equality, not text equivalence.
2. Setup: create a demo directory
Use an existing Python 3.9 or newer interpreter; no third-party packages are needed. Examples were tested on Linux with Python 3.11.4, using bash/zsh-compatible shell commands. Choose a fresh directory name when repeating the experiment.
python3 --version
mkdir filecmp-demo
cd filecmp-demo
Save the next section as demo_filecmp.py. It creates and overwrites only left.bin and right.bin in the current demo directory; do not run it alongside real files with those names.
3. Reproduce: matching timestamps do not imply matching content
import filecmp
import os
from pathlib import Path
left = Path("left.bin")
right = Path("right.bin")
stamp = 1_700_000_000_000_000_000
def write_at_fixed_time(path, data):
path.write_bytes(data)
os.utime(path, ns=(stamp, stamp))
write_at_fixed_time(left, b"alpha\n")
write_at_fixed_time(right, b"omega\n")
filecmp.clear_cache()
print("default:", filecmp.cmp(left, right))
print("deep:", filecmp.cmp(left, right, shallow=False))
write_at_fixed_time(right, b"alpha\n")
filecmp.clear_cache()
assert filecmp.cmp(left, right, shallow=False) is True
write_at_fixed_time(right, b"omega\n")
print("cached deep:", filecmp.cmp(left, right, shallow=False))
filecmp.clear_cache()
print("cleared deep:", filecmp.cmp(left, right, shallow=False))
Run:
python3 demo_filecmp.py
Expected output:
default: True
deep: False
cached deep: True
cleared deep: False
We deliberately fix timestamps using os.utime(..., ns=...), giving equal-length files matching signatures. Timestamp-preserving copies can create similar situations in real workflows; waiting another second is not a fix for comparison logic.
cached deep: True demonstrates a second issue: this process first compares equal content, then changes the right file while retaining size and modification time. An earlier deep-comparison result can still be reused. The official clear_cache() documentation provides the reset mechanism. shallow=False does not disable the cache.
4. Implementation: a read-only comparison command
Save as compare_files.py:
"""Compare two stable local regular files by content. Python 3.9+."""
import argparse
import filecmp
from pathlib import Path
import sys
def same_content(left, right):
for path in (left, right):
if not path.is_file():
raise ValueError(f"not a regular file: {path}")
# Disable metadata-only equality and discard earlier comparison results.
filecmp.clear_cache()
return filecmp.cmp(left, right, shallow=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("left", type=Path)
parser.add_argument("right", type=Path)
args = parser.parse_args()
try:
equal = same_content(args.left, args.right)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print("SAME" if equal else "DIFFERENT")
return 0 if equal else 1
if __name__ == "__main__":
raise SystemExit(main())
In same_content(), shallow=False changes the equality decision, while clear_cache() avoids reusing earlier results. Clearing affects the entire process’s filecmp cache, so this small command is intended to run independently rather than manage a cache shared across threads.
The regular-file check follows symlinks. It catches common directory and missing-file inputs, but does not defend against concurrent path replacement. Do not use it on untrusted, constantly changing directories.
5. Usage and validation: inspect output and exit codes
Run in the same demo directory. The cp command only changes the demo’s right file to equal content; the comparison tool itself does not modify either file.
python3 compare_files.py --help
python3 compare_files.py left.bin right.bin; printf 'exit=%s\n' "$?"
cp left.bin right.bin
python3 compare_files.py left.bin right.bin; printf 'exit=%s\n' "$?"
python3 compare_files.py left.bin missing.bin; printf 'exit=%s\n' "$?"
Excluding --help, results appear in this order; the error message goes to standard error:
DIFFERENT
exit=1
SAME
exit=0
ERROR: not a regular file: missing.bin
exit=2
The demonstration intentionally returns 1 and 2. Do not paste it unchanged into a script using set -e. For automation, capture the status immediately and distinguish a difference from a failed check:
status=0
python3 compare_files.py left.bin right.bin || status=$?
case "$status" in
0) printf 'verified same content\n' ;;
1) printf 'content differs; review before continuing\n' ;;
*) printf 'comparison failed; stop the job\n' >&2 ;;
esac
This demonstrates branching but does not terminate the surrounding job; production scripts should explicitly exit or return an error in failure branches. To track additions, missing files, and changes across a directory over time, use a SHA-256 dataset manifest.
Next, save the following as check_compare.py and run python3 check_compare.py. It uses an automatically cleaned temporary directory to check empty files, different lengths, equal content, the same path, non-UTF-8 bytes, a tail difference beyond 1 MiB, missing files, and directory inputs:
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
script = Path("compare_files.py").resolve()
def check(left, right, status, stdout):
result = subprocess.run(
[sys.executable, str(script), str(left), str(right)],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == status, result
assert result.stdout == stdout, result
if status == 2:
assert result.stderr.startswith("ERROR:"), result
else:
assert result.stderr == "", result
with TemporaryDirectory(prefix="filecmp-check-") as name:
root = Path(name)
a, b = root / "a.bin", root / "b.bin"
a.write_bytes(b"")
b.write_bytes(b"")
check(a, b, 0, "SAME\n")
a.write_bytes(b"A")
check(a, b, 1, "DIFFERENT\n")
b.write_bytes(b"A")
check(a, b, 0, "SAME\n")
check(a, a, 0, "SAME\n")
a.write_bytes(b"\x00\xff")
b.write_bytes(b"\x00\xfe")
check(a, b, 1, "DIFFERENT\n")
a.write_bytes(b"x" * 1_048_576 + b"A")
b.write_bytes(b"x" * 1_048_576 + b"B")
check(a, b, 1, "DIFFERENT\n")
b.write_bytes(a.read_bytes())
check(a, b, 0, "SAME\n")
check(a, root / "missing.bin", 2, "")
check(a, root, 2, "")
print("OK: 9 CLI checks")
Expect OK: 9 CLI checks. Section 3 separately tests equal size/timestamps and same-process caching; ordinary equal-content cases alone do not cover those pitfalls.
6. Troubleshooting: unexpected results
| Symptom | Concrete action |
|---|---|
Default comparison returns True for different bytes |
Pass shallow=False; do not substitute modification times for content checks. |
| Deep comparison still returns an old result | Run filecmp.clear_cache() before the next comparison and ensure writers have stopped. |
not a regular file |
Check the working directory and path. This example rejects directories, broken symlinks, and missing files. |
PermissionError or another read error |
Use an authorized account and the correct path; do not hide an exception by reporting equality. |
Text looks identical but returns DIFFERENT |
Byte comparison preserves CRLF, BOM, and final-newline differences. Use the text tool in section 1 when normalization is intended. |
| Results vary while files are being written | Stop writers or compare completed independent copies. Clearing a cache does not provide locking or a consistent snapshot. |
For duplicate candidates across a large directory, start with the read-only duplicate-file report instead of manually comparing every pair. Reading content can update access times; “read-only” means no content writes, not a guarantee that all metadata stays unchanged.
7. Summary
Before comparing files, decide whether metadata-only equality and cached results are acceptable. This command uses shallow=False with a cache reset before each comparison, then separates equality, differences, and errors with exit codes 0, 1, and 2. Stop concurrent changes first: it does not replace concurrency control, a complete backup-integrity strategy, or configuration semantic validation.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-filecmp-shallow-byte-comparison.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Compare CSV Snapshots by ID with Python: Added, Removed, and Changed Records
- Python Output Path Checks: Containment with pathlib and No Accidental Overwrites
- Fix Python Process Pool Startup Errors: spawn, the Main Guard, and Worker Tests
- Check Filename Case and Unicode Collisions with Python Before Copying Projects
- Fix Python venv bad interpreter After Moving a Project