~/systems/file-dedup
File deduplication
Find identical files without reading most of them: group by size for free, then by the first bytes, and only then by a hash of the whole file.
A funnel of cheaper to dearer keys. Bucket by size (no reads), drop buckets of one, bucket the rest by a short prefix, drop again, and hash or compare full contents only for what's left.
Find duplicate files or photos, clean up a sync folder, deduplicate uploads or backups, or detect identical content at scale.
O(files) plus the bytes actually read
O(files)
You’ll recognise it when
- You must find files with identical content: duplicate photos, notes synced twice, repeated uploads.
- There are many files, some of them huge, so reading everything is too slow or too big for memory.
- The follow-ups ask what you’d do at scale: millions of files, files that share a long header, symlinks, hash collisions.
It’s often confused with grouping strings in a hash map, and the core is exactly that: group by a key. The skill being tested is choosing keys so that most files are never opened at all.
The idea
To find two identical books in a huge library, you wouldn’t read every book. First you’d line them up by page count: a book with a page count nobody else has can’t have a twin. Among books with the same count, you’d compare the first page. Only books that still match after that are worth reading cover to cover.
Deduplication is that funnel. Size is free: the file system knows it without opening the file. A prefix (the first few kilobytes) is cheap and separates most files that happen to share a size. A hash of the whole content is expensive, so it’s only computed for files that survived both earlier rounds. After every round, drop the groups of one: a file with no partner can’t be a duplicate.
How it works
The code models the disk as a map from path to contents and counts the bytes it reads, so you can see what the funnel saves.
- Bucket by size. One pass, no reads.
by_sizeholds the paths in each bucket of two or more; every other file is finished: it has no duplicate. - Bucket each size group by prefix. Read the first
prefix_lenbytes of each candidate.by_prefixkeeps the sub-buckets of two or more. Files that differ in their first bytes stop here, after a tiny read. - Bucket by full content. For the survivors, read the rest. On disk you’d hash it in chunks (SHA-256) so memory stays flat;
by_contentgroups files with the same digest. - Report each final group, sorted, and the groups in sorted order.
With prefix_len = 4:
| file | size | after size | after prefix | after content |
|---|---|---|---|---|
/a/cat.jpg MEOWMEOWPURR |
12 | kept | MEOW kept |
duplicate |
/b/cat-copy.jpg MEOWMEOWPURR |
12 | kept | MEOW kept |
duplicate |
/a/cat-edit.jpg MEOWMEOWHISS |
12 | kept | MEOW kept |
alone |
/b/dog.jpg WOOFWOOFWOOF |
12 | kept | WOOF: alone |
|
/notes.txt, /c/big.bin |
5, 20 | unique size | ||
/c/empty1, /c/empty2 |
0 | kept | empty, kept | duplicates |
The two unique-size files are never opened. The dog photo costs 4 bytes. Only the three cat photos are read in full. That’s 40 bytes read out of 73 in total.
Why it’s correct: two files with identical contents have the same size, the same prefix and the same full contents, so every round keeps them in the same bucket. A file is dropped only when its bucket has no other member, which means nothing else matches it on a key that duplicates must share.
from collections import defaultdict
def group(paths, key):
"""Buckets paths by key(path); keeps only buckets with two or more."""
buckets = defaultdict(list)
for p in paths:
buckets[key(p)].append(p)
return [b for b in buckets.values() if len(b) >= 2]
def find_duplicates(files, prefix_len):
"""files: path -> contents. Returns (duplicate groups, bytes read)."""
read = 0
def prefix(p): # reading the start of a file costs bytes
nonlocal read
read += min(prefix_len, len(files[p]))
return files[p][:prefix_len]
def content(p): # the rest of it, only for real candidates
nonlocal read
read += max(0, len(files[p]) - prefix_len)
return files[p] # on disk: a SHA-256 of the file, read in chunks
groups = []
for by_size in group(files, lambda p: len(files[p])): # free: no file is opened
for by_prefix in group(by_size, prefix):
for by_content in group(by_prefix, content):
groups.append(sorted(by_content))
return sorted(groups), read#include <algorithm>
#include <map>
#include <string>
#include <utility>
#include <vector>
using namespace std;
// Buckets paths by key(path); keeps only buckets with two or more.
template <class Key>
vector<vector<string>> group(const vector<string>& paths, Key key) {
map<decltype(key(paths[0])), vector<string>> buckets;
for (auto& p : paths) buckets[key(p)].push_back(p);
vector<vector<string>> out;
for (auto& [k, b] : buckets)
if (b.size() >= 2) out.push_back(b);
return out;
}
// files: path -> contents. Returns {duplicate groups, bytes read}.
pair<vector<vector<string>>, long long> find_duplicates(const map<string, string>& files, size_t prefix_len) {
long long read = 0;
auto size = [&](const string& p) { return files.at(p).size(); }; // free: no file is opened
auto prefix = [&](const string& p) { // reading the start costs bytes
const string& data = files.at(p);
read += min(prefix_len, data.size());
return data.substr(0, prefix_len);
};
auto content = [&](const string& p) { // the rest, only for real candidates
const string& data = files.at(p);
read += data.size() > prefix_len ? data.size() - prefix_len : 0;
return data; // on disk: a SHA-256 of the file, read in chunks
};
vector<string> paths;
for (auto& [p, data] : files) paths.push_back(p);
vector<vector<string>> groups;
if (paths.empty()) return {groups, read};
for (auto& by_size : group(paths, size))
for (auto& by_prefix : group(by_size, prefix))
for (auto by_content : group(by_prefix, content)) {
sort(by_content.begin(), by_content.end());
groups.push_back(by_content);
}
sort(groups.begin(), groups.end());
return {groups, read};
}import java.util.*;
import java.util.function.Function;
class Dedup {
long read = 0; // bytes read so far
// Buckets paths by key(path); keeps only buckets with two or more.
static List<List<String>> group(List<String> paths, Function<String, Object> key) {
Map<Object, List<String>> buckets = new LinkedHashMap<>();
for (String p : paths) buckets.computeIfAbsent(key.apply(p), k -> new ArrayList<>()).add(p);
List<List<String>> out = new ArrayList<>();
for (List<String> b : buckets.values()) if (b.size() >= 2) out.add(b);
return out;
}
// files: path -> contents. Returns the duplicate groups; `read` counts the bytes read.
List<List<String>> findDuplicates(Map<String, String> files, int prefixLen) {
Function<String, Object> size = p -> files.get(p).length(); // free: no file is opened
Function<String, Object> prefix = p -> { // reading the start costs bytes
String data = files.get(p);
read += Math.min(prefixLen, data.length());
return data.substring(0, Math.min(prefixLen, data.length()));
};
Function<String, Object> content = p -> { // the rest, only for real candidates
String data = files.get(p);
read += Math.max(0, data.length() - prefixLen);
return data; // on disk: a SHA-256 of the file, read in chunks
};
List<List<String>> groups = new ArrayList<>();
for (List<String> bySize : group(new ArrayList<>(files.keySet()), size))
for (List<String> byPrefix : group(bySize, prefix))
for (List<String> byContent : group(byPrefix, content)) {
Collections.sort(byContent);
groups.add(byContent);
}
groups.sort((a, b) -> {
for (int i = 0; i < Math.min(a.size(), b.size()); i++) {
int c = a.get(i).compareTo(b.get(i));
if (c != 0) return c;
}
return Integer.compare(a.size(), b.size());
});
return groups;
}
}On a real disk
import hashlib
import os
def regular_files(root):
for dirpath, dirnames, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
if not os.path.islink(path) and os.path.isfile(path):
yield path # skip symlinks and special files
def file_digest(path, chunk=1 << 16):
h = hashlib.sha256()
with open(path, "rb") as f:
while block := f.read(chunk): # 64 KiB at a time: memory stays flat
h.update(block)
return h.digest()
os.path.getsize(path) gives the size without reading the file. Open files in binary mode, so line endings aren’t translated and every byte counts.
Why it’s cheap
Bucketing is O(1) per file per round with a hash map, so the bookkeeping is O(files). The real cost is reading bytes, and the funnel decides how many: a file with a unique size costs nothing, one that differs early costs prefix_len bytes, and only true candidates are read in full. In the worst case (many large files of the same size with the same header), the last round still reads them all, but never more than once each.
| round | cost per file | files that pay it |
|---|---|---|
| size | free (metadata) | all of them |
| prefix | up to prefix_len bytes |
files that share a size |
| full hash | the whole file | files that share size and prefix |
Hashing a file is limited by the disk, not the CPU, so the last round parallelises well across a thread pool.
Common mistakes
Hashing every file
Reading every byte of every file works, and is exactly what the question wants you to avoid. A file with a unique size can’t have a duplicate.
groups = group(paths, file_digest) # ✗ reads everything
for by_size in group(paths, os.path.getsize): ... # ✓ free first round
Reading whole files into memory
f.read() on a 20 GB video needs 20 GB of memory. Feed the hash in fixed-size chunks.
h.update(open(path, "rb").read()) # ✗ the whole file at once
while block := f.read(1 << 16): h.update(block) # ✓ 64 KiB at a time
Following symlinks
A symlink to a file looks like a duplicate of it, and a symlink to a folder can make a walk loop forever or count the same files twice. Skip links unless the question says otherwise.
if os.path.isfile(path): ... # ✗ true for a link to a file
if not os.path.islink(path) and os.path.isfile(path): ... # ✓ regular files only
Forgetting the groups of one
After each round, a bucket with one file means that file is done. Passing it on to the next round reads it for nothing.
for bucket in buckets.values(): next_round(bucket) # ✗ singles get read too
if len(bucket) >= 2: next_round(bucket) # ✓ only real candidates
Variations
- Comparing in lockstep. Instead of hashing whole files, read the candidates of one bucket chunk by chunk side by side, splitting the bucket whenever chunks differ. A file stops being read as soon as it’s alone, which wins when files differ late.
- Normalised duplicates. Notes that differ only in line endings or trailing blank lines count as the same: normalise the content (replace
\r\nwith\n, strip trailing newlines) before keying. Decide which copy to keep with an explicit rule. - Hash collisions. SHA-256 collisions aren’t a practical concern, but a fast non-cryptographic hash can collide. If you’re paranoid, confirm a group with a byte-by-byte comparison (
filecmp.cmp(a, b, shallow=False)). - Near duplicates. Similar but not identical files (resized photos, edited documents) need fingerprints that survive small changes, such as chunk hashes with content-defined boundaries or perceptual hashes for images.
- Many machines. Bucket by size on each machine, exchange only the sizes that appear more than once, and hash those.
Climb the ladder
Our file deduplication problems in ladder order.
- Group paths by identical content: grouping by a digest, in memory.
- Clean up notes synced from two laptops: normalise before comparing, and pick which copy to keep.
- Find duplicate files (size, prefix, content): walk a real directory, then a prefix round, then lockstep comparison.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
A deduplicator buckets files by size, then by their first 4 KB, then by a full hash. Which file is never opened at all?
Size comes from the file system’s metadata, and a file with a unique size can’t have an identical twin, so it’s dropped before any read. Empty files share size 0, so they stay in the funnel (reading them costs nothing). The largest file is skipped only if its size is unique.
-
2
What does this first round of the funnel print?
from collections import defaultdictfiles = {"a": "xxxx", "b": "xxyy", "c": "xxxx", "d": "zz"}by_size = defaultdict(list)for path, data in files.items():by_size[len(data)].append(path)print([g for g in by_size.values() if len(g) >= 2])a,bandcall have 4 bytes, so they share a bucket even thoughbdiffers: size alone can’t tell them apart, which is what the later rounds are for.dis alone in its size bucket and is dropped, since it can’t have a duplicate. -
3
A deduplicator hashes each candidate with
hashlib.sha256(open(path, "rb").read()).digest(). On a folder with 20 GB videos, what goes wrong?read()with no size loads the entire file, so memory use matches the largest file. Callingupdateon 64 KiB chunks gives the same digest with flat memory. SHA-256 handles inputs of any practical size, and binary mode is what keeps the bytes unchanged. -
4
A folder holds
photo.jpgandlink.jpg, a symlink tophoto.jpg. What does a deduplicator report if it treats every nameos.walklists as a regular file?os.walklists a symlink to a file among the file names, and opening it reads the target, so the contents match exactly. Deleting the “duplicate” would then leave a broken link or remove the only real copy. Skip links withos.path.islink. -
5
Ten candidate files share the same size and the same first 1 KB, and usually differ somewhere in the middle. Which last round reads the fewest bytes and still proves which are identical?
Reading in lockstep splits the bucket as soon as chunks differ, so a file stops being read about where it diverges. A full hash reads every byte of every file. Pairwise comparison reads each file up to 9 times. The last kilobyte can match while the middle differs, so it proves nothing.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.