Last quarter I had to validate a nightly export pipeline that dropped 120 CSVs into a staging folder before a billing cutover. A quick diff of folders wasn’t enough; I needed to prove that specific files matched across two locations and surface the handful that didn’t. That’s exactly where Python’s filecmp.cmpfiles() shines. It compares a list of filenames across two directories and returns three buckets: matches, mismatches, and errors. I reach for it when I already know which files matter and want a repeatable check I can run locally or in CI.
If you are syncing data, verifying build artifacts, or auditing a backup, this method gives you a precise, scriptable way to answer “what matches and what doesn’t” without writing a bespoke diff engine. You’ll see how cmpfiles() works, how shallow versus deep comparisons change the outcome, and the workflow patterns I rely on to make results trustworthy. Along the way I’ll call out edge cases, common mistakes, and performance tradeoffs so you can make the method work on real projects rather than toy examples.
Where cmpfiles fits in the filecmp toolkit
Python’s filecmp module lives in the standard library and gives you a small set of tools to compare files and directories. At the simplest level, filecmp.cmp() compares one file pair and returns True or False. filecmp.dircmp() compares directory trees and exposes lists of common, unique, and funny files. cmpfiles() sits in the middle: it compares a specific list of names across two directories and reports the outcome as three lists.
The call looks like filecmp.cmpfiles(dir1, dir2, common, shallow=True). dir1 and dir2 can be strings, bytes, or Path objects. common is a list of base filenames, not paths. For each name, cmpfiles() checks dir1/name against dir2/name and returns a tuple of (match, mismatch, errors). match holds names that compare equal, mismatch holds names that compare unequal, and errors holds names it could not compare because of missing files, permission issues, or other I/O problems. The method does not recurse; you decide which names count as common.
One subtle point: entries in common can include subpaths like reports/2026-01.csv. cmpfiles() simply joins the name to both directories, so as long as the relative layout is the same in both locations, nested comparisons work fine. If you need recursion, generate this list yourself with Path.rglob() and Path.relative_to() so you keep full control over which files appear.
I use cmpfiles() when I already have a manifest of what must match. That might be a data export that must produce customers.csv, orders.csv, and refunds.csv each night, or a build step that writes app.bundle.js and app.bundle.css. In those cases I don’t want a full directory diff. I want a clean, predictable answer for a known list, and cmpfiles() gives me that in one call.
Shallow vs deep comparison: labels vs contents
By default, cmpfiles() performs a shallow comparison. It asks the OS for file metadata—specifically size and modification time—and treats files as equal when those values line up. That can be blazingly fast and is often accurate enough when any rewrite also updates timestamps and file size. In CI pipelines that generate new artifacts from scratch, I’m usually fine with shallow checks for a quick smoke test.
The moment I’m validating data or backups, though, I switch to deep comparisons. Shallow checks can return false matches when tools preserve timestamps during copy, when files are overwritten with equal-sized content, or when you’re on filesystems with coarse timestamp resolution. It’s also common for copy operations to preserve metadata by design, which makes a shallow compare actively misleading. Setting shallow=False forces cmpfiles() to read both files and compare byte by byte. It costs more I/O, but it restores my confidence that a match really means identical contents.
I decide between shallow and deep checks based on how catastrophic a false match would be. My rule of thumb:
- Shallow is fine when speed matters more than certainty and a false match would be annoying but not dangerous.
- Deep is mandatory when the output is a source of truth: billing, reporting, compliance, or backups.
- Mixed strategies work well when I want a fast preflight followed by a deep check on only the files I care about most.
Choosing or building the right manifest
cmpfiles() is only as good as the list of names you feed it, so I invest time in the manifest. In small workflows I’ll hardcode a list, but as soon as it reaches dozens of files I build it programmatically and keep it sorted. Ordering might seem cosmetic, yet it makes diffs, logs, and CI output far easier to read. I also treat the manifest as a contract: if a file is not on the list, it does not matter for that comparison run.
The easiest way to produce a manifest is to walk one directory and calculate relative paths. I usually build the list from the source of truth and then use it to compare against the target.
from pathlib import Path
def build_manifest(root, extensions=None):
root = Path(root)
extensions = set(extensions or [])
names = []
for path in root.rglob(‘*‘):
if not path.is_file():
continue
if extensions and path.suffix not in extensions:
continue
names.append(str(path.relative_to(root)))
return sorted(names)
This pattern gives me a stable list of relative paths like reports/2026-01.csv. I can save that list to a text file, commit it, or regenerate it on demand. If you need to exclude temp files or logs, filter them here—don’t rely on cmpfiles() to guess what you meant.
A complete comparison script I actually use
When I want a repeatable, CLI‑friendly check, I wrap cmpfiles() in a small script with clear exit codes and a single source of truth for the manifest. This is a trimmed-down version of what I run in CI and on my laptop:
import argparse
import filecmp
from pathlib import Path
import json
import sys
def load_manifest(path):
return [line.strip() for line in Path(path).read_text().splitlines() if line.strip()]
def run_compare(dir1, dir2, manifest, shallow):
return filecmp.cmpfiles(dir1, dir2, manifest, shallow=shallow)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(‘dir1‘)
parser.add_argument(‘dir2‘)
parser.add_argument(‘–manifest‘, required=True)
parser.addargument(‘–deep‘, action=‘storetrue‘)
parser.add_argument(‘–json-out‘)
args = parser.parse_args()
manifest = load_manifest(args.manifest)
match, mismatch, errors = run_compare(args.dir1, args.dir2, manifest, shallow=not args.deep)
result = {
‘match‘: match,
‘mismatch‘: mismatch,
‘errors‘: errors,
‘dir1‘: str(Path(args.dir1)),
‘dir2‘: str(Path(args.dir2)),
}
if args.json_out:
Path(args.jsonout).writetext(json.dumps(result, indent=2))
if errors:
return 3
if mismatch:
return 2
return 0
if name == ‘main‘:
sys.exit(main())
The script is intentionally boring: deterministic, no magic. A non-zero exit code makes it CI‑friendly, and the optional JSON output turns it into an audit artifact. I can tuck that JSON into a build log or a ticket and not worry about manual parsing later.
Reading results like a checklist
The three lists returned by cmpfiles() are your accountability buckets. match means files compared equal; mismatch means files compared and differed; errors means the comparison didn’t happen at all. I treat errors as the most serious category because they usually mean the process is broken, not just inconsistent. Missing files, permission problems, or path mismatches all end up in the error list.
I also normalize the lists before reporting: I sort them, I remove duplicates from the manifest if any slipped in, and I capture counts alongside names. That is less about correctness and more about making the results readable. If I hand a report to a teammate, I want them to see a summary at a glance and drill down only if needed.
Practical scenarios I rely on
The first place I apply cmpfiles() is data exports. When a pipeline writes a fixed set of tables every night, I can keep a manifest and compare the staging output to a baseline. Deep comparisons catch the files that didn’t actually update, while shallow comparisons catch files that went missing or are drastically smaller than expected. In that context, I use cmpfiles() as a binary check: every file must match or the run is flagged.
I also use it for build artifacts. Release bundles are small and predictable—JavaScript, CSS, and static assets. I compare a candidate build to a known‑good build in a hotfix situation, or I compare artifacts generated in two environments to be sure the build is deterministic. Here I’m usually fine with shallow comparisons for speed, but I flip to deep if I’m debugging a reproducibility issue.
Backups are the third scenario. I never trust a backup until I can prove it. Instead of comparing two entire trees, I run cmpfiles() on the most important subsets: database dumps, config snapshots, and critical reports. It gives me a pragmatic balance between effort and confidence. If those key files match byte‑for‑byte, I can accept the backup while scheduling a deeper audit later.
Edge cases and traps I’ve learned to avoid
cmpfiles() is simple, but the real world isn’t. These are the traps I watch for:
- Line ending differences (
\nvs\r\n) in CSVs and logs that are identical by meaning but not by bytes. - Files generated by tools that preserve timestamps even when content changes.
- Copy operations that preserve metadata and cause shallow comparisons to lie.
- Symlinks or special files accidentally included in the manifest;
cmpfiles()expects regular files. - Directories included in the manifest by mistake, especially when you build the list by string concatenation.
- Case sensitivity differences on mixed OS filesystems where
Foo.csvandfoo.csvcollapse. - Files that are still being written when the comparison starts, leading to a mismatch that disappears on rerun.
The fix is almost always the same: tighten the manifest, ensure file writes are complete before comparing, and run deep checks when correctness matters.
Performance and scaling tradeoffs
The runtime of cmpfiles() scales with the number of files and, in deep mode, their total size. For tens or even hundreds of files it’s fast and almost always dominated by disk I/O. As you move into thousands of files or multi‑gigabyte datasets, deep comparisons can become slow enough to block a pipeline.
I handle performance in two ways. First, I keep the manifest tight; I don’t compare files that don’t matter. Second, I schedule comparisons at the right time. A deep compare on a quiet staging server at 2 a.m. is far less painful than on a shared CI runner at noon. If I’m on a network share, I also expect higher latency and longer reads. In that environment, shallow checks can be a quick sanity pass, while deep checks are reserved for critical subsets.
A two‑pass strategy for speed and safety
When I have to balance speed and correctness, I run cmpfiles() twice. The first pass uses shallow comparisons to identify obvious mismatches and missing files. The second pass uses deep comparisons for the files that are most important or for the files that passed the shallow check but are part of a compliance‑sensitive set. This approach keeps I/O manageable while still giving me a high‑confidence answer for the files that matter.
I’ve also used a three‑tier strategy: shallow for everything, deep for the critical set, and a separate hash‑based check for large files where I need a stable fingerprint for auditing. That sounds heavy, but in practice it’s cheaper than deep‑comparing hundreds of gigabytes every night.
CI and automation patterns that work well
In CI, I keep cmpfiles() checks early and explicit. A compare step should fail fast with a clear log. I run a pre‑check that confirms both directories exist, then I call the compare script and treat any non‑zero exit code as a failure. Because the results are deterministic, this step is easy to reason about, and the blame is clear: either the files match or they don’t.
I also version the manifest alongside the pipeline code. When new files are added, the manifest update becomes a visible change. That prevents silent drift where the comparison is no longer aligned with what the pipeline produces.
Monitoring and auditability
For long‑running pipelines, I push cmpfiles() results into logs and monitoring. A daily mismatch count is an easy metric to track. I can alert when the mismatch count goes above zero or when the error list is non‑empty. The JSON output from my script becomes an artifact that compliance teams can keep for reference.
This kind of audit trail matters in regulated environments. You don’t want to argue about what happened last Tuesday; you want a file you can point to that says exactly which files matched and which did not.
Alternatives and complements to cmpfiles
cmpfiles() is not the only way to compare files, and in some cases it isn’t the best tool. I think about it as part of a toolkit:
Best for
—
filecmp.cmp() One‑off comparisons of two known files
filecmp.cmpfiles() Targeted lists across two dirs
filecmp.dircmp() Exploratory directory diffs
hashlib Stable fingerprints and auditing
rsync --dry-run or diff Large tree comparisons
CSV or database content checks
I reach for cmpfiles() when I need a simple, standard library solution with predictable output. If I need semantically aware comparisons—like ignoring row order in a CSV—I move to data‑level validation.
When not to use cmpfiles
There are scenarios where cmpfiles() is the wrong tool. If your files are huge and you need to compare only a portion, a streaming or chunked approach might be better. If you need to ignore whitespace, line endings, or sorted order, byte‑level comparisons will give you too many false mismatches. And if you care about metadata like permissions or ownership, cmpfiles() will not help; it only compares content and shallow metadata for speed.
In those cases I either preprocess the files into a normalized form or I use a domain‑specific tool that understands the file format. The key is not to force cmpfiles() into a role it was never designed for.
Modern tooling and AI‑assisted workflows
I sometimes use AI tooling to help draft or update manifests, especially when the list of files is long and changes often. It can also help summarize mismatches into human‑readable reports. But I keep the comparison itself deterministic and in code. The comparison step must be repeatable and auditable, and that means no non‑deterministic steps in the actual comparison flow.
Debugging mismatches quickly
When cmpfiles() reports a mismatch, I take a structured approach. First I check size and timestamps to confirm the mismatch is real and not an in‑flight write. Then I compute a hash to confirm the files truly differ. If they do, I open both files and compare the first few lines or records to understand whether the difference is ordering, formatting, or actual content.
For CSVs, the difference is often line ending or column ordering. For build artifacts, it might be a timestamp or a build ID embedded in the file. Once I understand the difference, I can decide whether the mismatch is a problem or an acceptable variance.
My production checklist
When I deploy a cmpfiles() check, I keep a short checklist:
- Build and version a manifest with relative paths.
- Decide shallow vs deep based on risk.
- Ensure file writes are complete before comparing.
- Treat any error list entries as failures.
- Log counts and the full mismatch list.
- Save a JSON artifact for audits.
- Add a small sanity test that compares a known‑good pair.
- Revisit the manifest when the pipeline changes.
cmpfiles() is deceptively small, but it becomes powerful when you pair it with these operational habits. It gives you a compact, repeatable answer to a very real question: are the files I care about truly identical? When you can answer that with confidence, the rest of your pipeline becomes easier to trust.


