Find and remove duplicate files across drives, safely (report, dry-run, Recycle Bin, keep-one-per-group).
Project description
dupe_finder
A single-file Python utility (stdlib only, no dependencies) that finds duplicate files across your drives, tells you how much space you'd reclaim before deleting anything, and helps you delete redundant copies while always keeping exactly one.
How it detects duplicates
Files are compared by content, not name. To stay fast on large drives it works in three cheap-to-expensive stages, and only advances files that survive each:
- Group by size — files with a unique size can't have a duplicate.
- Partial hash — hash the first 64 KB of each same-size file.
- Full hash — hash the entire file only when the partial hashes collide.
Only files with an identical full-content hash are reported as duplicates, so
same-size-but-different files are never falsely matched. Both hash passes run in
parallel across worker threads (tunable with --workers).
Files of 64 KB or less are read only once: the partial pass has already hashed them end to end, so that digest is reused instead of reading them again.
Long paths on Windows
Windows' legacy 260-character path limit means the usual open()/stat() calls
fail on deeply nested files — the sort of thing node_modules produces by the
thousand. Every filesystem access goes through the \\?\ extended-path form, so
those files are scanned, hashed and removed like any other.
This matters more than it sounds: on a 138k-file developer tree, 977 duplicate files were invisible to versions before 2.0 purely because their paths were too long.
--fast: a lightweight first pass
For a quick survey of a big drive, --fast skips hashing entirely and matches
files on file name + exact size alone. Nothing is ever read from disk, so it
finishes in seconds rather than minutes.
The trade-off is that it's a heuristic, not a proof:
- False positives — two different edits of
report.docxcan easily end up the same size.--fastwould call them duplicates; they aren't. - False negatives — an identical file saved under a different name (say
song.mp3andsong (1).mp3) is invisible to--fast, but a normal run finds it.
So results are labelled likely copies, the summary carries a warning, and if
you go on to delete them the confirmation prompt asks you to type
delete unverified rather than a reflexive yes. Use it to find out where
your duplicates are, then re-run without --fast (optionally narrowed to that
folder) to confirm by content before deleting.
Progress output
Long scans report progress on a single live line — percentage complete, work done vs. total, and an estimated time remaining:
full scan | 42.7% | 18.30 GB / 42.86 GB | ETA 3:12
The hashing phase measures progress in bytes, not files, so the ETA isn't thrown off by a mix of tiny and huge files. Three levels of detail:
| Flag | Output |
|---|---|
| (default) | Live progress line + the 20 largest duplicate groups + totals. |
-v, --verbose |
Per-file scanning/hashing detail and every duplicate group. |
-q, --quiet |
No progress at all; just the report. |
Progress goes to stderr, so python dupe_finder.py > report.txt keeps the
report clean while you still watch progress on screen. When stderr is
redirected, progress is appended as occasional lines instead of a rewritten one.
Upgrading to 2.0
Duplicate spellings of four options were removed, so there is now exactly one name for each. Running a removed flag prints the replacement rather than a bare "unrecognized arguments":
| Removed | Use instead |
|---|---|
--ext |
--type |
--kind |
--category |
--quick, --name-size |
--fast |
--no-cache |
--cache none |
2.0 also finds duplicates that earlier versions silently skipped — see long paths below.
Install
It's a single self-contained file — you can just run it:
python dupe_finder.py --help
Or install it as a proper command (dupe-finder) with pip or pipx:
pipx install . # from a checkout
pip install . # into the current environment
pip install .[recycle] # also pulls in send2trash for Trash support
Then:
dupe-finder --type pdf --dry-run
No third-party dependencies are required. send2trash is optional (see Recycle
Bin notes below). Requires Python 3.9+.
Usage
# Scan every drive and just report duplicates (deletes nothing)
python dupe_finder.py
# Scan specific folders
python dupe_finder.py -p D:\Photos -p "E:\Backup"
# Ignore files under 1 MB
python dupe_finder.py --min-size 1MB
# Quick survey: match on name + size only, never read file contents
python dupe_finder.py -p D:\Photos --fast
python dupe_finder.py --fast --min-size 10MB # where are the big duplicates?
# Only look at specific file types (comma-separated or repeated; dot optional)
python dupe_finder.py --type jpg,png,gif
python dupe_finder.py -p D:\Music --type .mp3 --type .flac
# Or use a friendly category instead of listing extensions
python dupe_finder.py --category movies # find duplicate videos
python dupe_finder.py --category music # 'music' == 'audio' == 'songs'
python dupe_finder.py --category photos,documents
# Report, then delete redundant copies keeping the OLDEST one, prompt to confirm
python dupe_finder.py --delete --keep oldest
# Decide group-by-group interactively (or press 'i' at the confirmation prompt)
python dupe_finder.py --delete --interactive
# Just write a reviewable plan to a file - deletes nothing, never prompts
python dupe_finder.py --category movies --review movie_dupes.txt
# Delete without a final prompt, writing a JSON record of what was removed
python dupe_finder.py --delete --keep shortest-path --yes --log deleted.json
# Send duplicates to the Recycle Bin instead of deleting permanently
python dupe_finder.py --recycle --keep oldest
# Preview the deletion plan (counts + space) without removing anything
python dupe_finder.py --type pdf --dry-run
# Prefer keeping copies on a specific drive/folder; fall back to --keep otherwise
python dupe_finder.py --type pdf --recycle --prefer L: --keep oldest
# Repeat runs reuse cached hashes (unchanged files aren't re-hashed)
python dupe_finder.py --type pdf # first run populates the cache
python dupe_finder.py --type pdf # second run is much faster
python dupe_finder.py --type pdf --cache none # force a full re-hash
Deciding at the prompt
After a --delete / --recycle scan you get the plan and a prompt. It is not a
yes-or-lose-your-scan choice — you can act on the results without ever re-running:
Proceed with recycling?
yes - recycle all 1,234 file(s) above
i - review and decide group by group
s - save a review file, then ask again
n - abort (a review file is saved first)
Choice:
iwalks the groups one at a time showing[12/340], the copy being kept and the copies being removed. Answery/nper group,ato accept the current group and all remaining,sto save the full plan without losing your place, orqto stop (everything you already approved is still carried out, and the groups you never got to are written to the review file).swrites the review file and returns to the prompt, so you can open it in another window and then decide right here.naborts and saves the review file first.- A typo re-prompts instead of aborting. An unrecognised answer never deletes.
The review file (duplicates_review.txt by default, --review FILE to choose)
lists every group largest-first with the keeper and the victims marked:
[1] 3 copies | 1.20 GB each | reclaimable 2.40 GB
KEEP D:\Movies\Arrival.mkv
remove E:\Backup\Arrival.mkv
remove F:\old\Arrival.mkv
It is also written automatically by --dry-run. If you do abort and come back
later, the re-run is much faster — the full-content hashes are already cached.
--review works on its own as a read-only mode — no deletion flag needed,
no prompt, nothing removed:
python dupe_finder.py --category movies --review movie_dupes.txt
That's the shortest way to get a complete, reviewable list of what the tool would do. (A plain run with no flags at all still writes nothing, so you never get a file you didn't ask for.)
Deletion safety
- Nothing is ever deleted in plain report mode (the default).
- Even with
--delete, you get a summary of space to reclaim and a confirmation prompt first (unless you pass--yes). - Declining always leaves you a review file, so a long scan is never wasted.
--dry-runshows exactly what would be removed (and how much space) without touching any files.- Every group always keeps one copy — you choose which with
--keep, and can bias the choice toward a location with--prefer. --recyclesends removed files to the Recycle Bin (recoverable) instead of deleting them permanently.--log FILEwrites a JSON record of every removed file and the copy it was kept against.- With
--fast, matches are unverified, so the confirmation prompt requires the phrasedelete unverifiedinstead ofyes. Prefer--recyclethere, and ideally confirm by content first.
Recycle Bin caveat: the bin has a per-drive size cap. Files larger than the available quota are permanently deleted rather than recycled (Windows behavior). When recycling many large files, don't rely on everything being recoverable.
Options
| Option | Description |
|---|---|
-p, --path DIR |
Directory to scan (repeatable). Default: all drives. |
-t, --type EXT |
Only scan these extensions, e.g. jpg,png (repeatable, leading dot optional, case-insensitive). Default: all files. |
--fast |
Lightweight first pass: match on name + size only, without reading file contents. Much faster, but results are unverified guesses — see --fast. |
-c, --category NAME |
Only scan a file-type category: movies, music, photos, documents, ebooks, archives, code (plus synonyms like video, audio, images). Repeatable/comma-separated; combines with --type. |
--min-size SIZE |
Ignore files smaller than this (e.g. 1MB). Default 1. |
--max-size SIZE |
Ignore files larger than this. |
--exclude SUBSTR |
Skip any path containing this substring (repeatable). |
--follow-symlinks |
Follow symbolic links (off by default). |
--delete |
Enter deletion mode after reporting. |
--recycle |
Send removed files to the Recycle Bin instead of deleting permanently (implies --delete). |
--keep STRATEGY |
Which copy to keep: oldest (default), newest, shortest-path, longest-path, first. |
--prefer SUBSTR |
Prefer keeping copies whose path contains this substring, e.g. --prefer L: (repeatable; earlier values win). Groups with no match fall back to --keep. |
--dry-run |
Show the deletion plan (counts and space) without removing anything. |
--interactive |
Confirm each duplicate group individually. |
--yes |
Skip the final confirmation prompt. |
--log FILE |
Write a JSON log of deleted files. |
--review FILE |
Write the human-readable plan to FILE. On its own it's a read-only mode — nothing is deleted, no prompt. Also written automatically on --dry-run and whenever you decline or stop a deletion (default ./duplicates_review.txt). |
--workers N |
Parallel hashing threads (default: based on CPU count). Use 1 for spinning disks where parallel reads thrash. |
--cache FILE |
Persistent hash-cache file (default: per-user cache dir). Unchanged files aren't re-hashed on repeat runs. Pass --cache none to disable caching for a run. |
-v, --verbose |
Show full detail: every duplicate group plus per-file scanning/hashing lines (replaces the progress line). |
-q, --quiet |
Suppress all progress output; print the report only. |
Notes
- When scanning whole drives / the filesystem root (no
-p), system locations are skipped by default, tailored per OS. Pass explicit-ppaths to scan them.- Windows:
\Windows\,\$Recycle.Bin,\System Volume Information,\ProgramData\,\AppData\. - macOS:
/System,/Library,/private,/Volumes,/Applications,/usr,~/Library, and friends. - Linux: pseudo-filesystems (
/proc,/sys,/dev,/run) plus common system trees (/usr,/var,/boot,/snap,/tmp, …). - Unix defaults are anchored at the root, so a user folder like
~/devor~/varis never mistaken for/devor/var.
- Windows:
- Scanning entire drives can take a while and touch many files; start with a
single folder, a
--min-sizefilter, or a--fastsurvey to get a feel for it. - Hashing is multi-threaded by default. On SSDs/NVMe this is a big speedup;
on a single spinning HDD, parallel reads can thrash the head — pass
--workers 1there. --recycleworks natively on every platform, no dependency required: the Windows shell API viactypes,~/.Trashon macOS, and the FreeDesktop trash spec on Linux (home trash, or the mount's.Trash-$uidfor other drives, with restorable.trashinfometadata). If thesend2trashpackage happens to be installed it's used in preference (most battle-tested), but it's optional.- Repeat scans are fast: full-content hashes are cached per user, keyed by
path + size + mtime, so unchanged files are never re-hashed. Control it with
--cache FILE/--cache none. - Categories filter which files are scanned; the tool still only ever removes
duplicates (keeping one copy per group).
--category musicfinds duplicate music files across your drives — it does not delete your whole music library. Because matching is by content, it also catches the same song saved under different names or extensions (e.g. a.mp3and.m4awith identical bytes). - Output is written with
errors="replace", so filenames containing characters the console can't display won't crash the run (they show a?placeholder). - Requires Python 3.9+.
Running the tests
tests.py is stdlib-only, like the tool itself — no pytest, no install step:
python tests.py
Every fixture is built in a temporary directory; nothing touches real files, the
Recycle Bin, or your hash cache. The suite includes a scan-equivalence check
that runs the previous os.walk logic as an oracle against the current
os.scandir implementation, so the fast path can't silently start matching a
different set of files.
Releasing (maintainers)
Releases are published to PyPI automatically by
.github/workflows/release.yml using PyPI Trusted Publishing (OIDC) — no
API tokens are stored. One-time setup:
- On PyPI, add a trusted publisher for the project
dupe-finder-cli: ownercanindya, repodupe-finder-cli, workflowrelease.yml, environmentpypi. (Do the same on TestPyPI with environmenttestpypiif you want to dry-run.) - In the GitHub repo settings, create environments named
pypi(and optionallytestpypi).
Then, to cut a release:
- Bump
versioninpyproject.tomland commit. - Publish a GitHub Release (tag e.g.
v1.0.1) — the workflow builds the sdist and wheel and publishes to PyPI.
To test the pipeline first, use Actions → Release to PyPI → Run workflow and
pick testpypi.
Recommended workflow
- Survey (optional) —
--fastover a big drive to see roughly where the duplicates live, in seconds. Treat the numbers as an estimate. - Report — run with a
--type/-pfilter to see duplicates and total reclaimable space, verified by content. - Preview — add
--dry-run(plus--prefer/--keep) to confirm the exact plan and where deletions would land. - Act — swap
--dry-runfor--recycle(or--delete) and add--logto keep a record.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dupe_finder_cli-2.0.0.tar.gz.
File metadata
- Download URL: dupe_finder_cli-2.0.0.tar.gz
- Upload date:
- Size: 34.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cbdd2559e1fdbae3c6d8527a5510ef4f8da50454be3923e110fc8cb847b85c30
|
|
| MD5 |
e564e4311eddc7db2b0c17a47d2fb399
|
|
| BLAKE2b-256 |
a37b1ed977dcc791e8f8d1178d6afc4a60f7145ec1744012f1943fd240791d37
|
Provenance
The following attestation bundles were made for dupe_finder_cli-2.0.0.tar.gz:
Publisher:
release.yml on canindya/dupe-finder-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dupe_finder_cli-2.0.0.tar.gz -
Subject digest:
cbdd2559e1fdbae3c6d8527a5510ef4f8da50454be3923e110fc8cb847b85c30 - Sigstore transparency entry: 2257197471
- Sigstore integration time:
-
Permalink:
canindya/dupe-finder-cli@745d0229cb84320463b72a2245bf0b8715c9dc13 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/canindya
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@745d0229cb84320463b72a2245bf0b8715c9dc13 -
Trigger Event:
release
-
Statement type:
File details
Details for the file dupe_finder_cli-2.0.0-py3-none-any.whl.
File metadata
- Download URL: dupe_finder_cli-2.0.0-py3-none-any.whl
- Upload date:
- Size: 29.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c1f8272b6d9adbc31c480ec2f0786c0a9c46fb072adb242a4a667d5b6d92bf62
|
|
| MD5 |
9d04813ef2be2ad9b3d960c6443951e4
|
|
| BLAKE2b-256 |
07526ebad6573948f83c1f68c9b9b77aeab2dbb1e160e477ad042bf47ad854c6
|
Provenance
The following attestation bundles were made for dupe_finder_cli-2.0.0-py3-none-any.whl:
Publisher:
release.yml on canindya/dupe-finder-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dupe_finder_cli-2.0.0-py3-none-any.whl -
Subject digest:
c1f8272b6d9adbc31c480ec2f0786c0a9c46fb072adb242a4a667d5b6d92bf62 - Sigstore transparency entry: 2257197479
- Sigstore integration time:
-
Permalink:
canindya/dupe-finder-cli@745d0229cb84320463b72a2245bf0b8715c9dc13 -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/canindya
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@745d0229cb84320463b72a2245bf0b8715c9dc13 -
Trigger Event:
release
-
Statement type: