File Synchronization Tool
Abstract
A sync tool is your introduction to serious filesystem work: walking directory trees, comparing modification times, copying with metadata intact, and — the scary part — deleting files. In this tutorial you build a Tkinter app that mirrors a source directory into a target, copying anything new or updated and removing anything that no longer exists at the source. Then you make it trustworthy: a dry-run preview before any destructive action, hash-based change detection, optional two-way sync, and a scrollable action log.
You will leave understanding:
- How
os.walkos.walktraverses a directory tree, andos.path.relpathos.path.relpathpreserves structure. - Why
getmtimegetmtimecomparison decides what’s “newer”. - Why
shutil.copy2shutil.copy2(notcopycopy) keeps timestamps and permissions. - How to make destructive tools safe with a preview-before-commit pattern.
Prerequisites
- Python 3.6 or above.
- A text editor or IDE.
- Tkinter (bundled with Python).
- Comfort with
ososandshutilshutilfrom the standard library. - Two test folders with throwaway files — never debug a sync tool on real data.
Getting Started
Create the project
- Create a folder named
file-sync-toolfile-sync-tool. - Inside it, create
file_synchronization_tool.pyfile_synchronization_tool.py. - Make two test folders (e.g.
src_testsrc_test,dst_testdst_test) with a few dummy files.
Write the code
file_synchronization_tool.py
Source"""
File Synchronization Tool
A tool to synchronize files between two directories with the following features:
- Detect and copy new or updated files
- Delete files that no longer exist in the source directory
- Provide a summary of synchronization actions
"""
import os
import shutil
from tkinter import Tk, Label, Entry, Button, messagebox, filedialog
class FileSynchronizationTool:
def __init__(self, root):
self.root = root
self.root.title("File Synchronization Tool")
self.source_dir = ""
self.target_dir = ""
self.setup_ui()
def setup_ui(self):
"""Set up the user interface."""
Label(self.root, text="Source Directory:").grid(row=0, column=0, padx=10, pady=10)
self.source_entry = Entry(self.root, width=50)
self.source_entry.grid(row=0, column=1, padx=10, pady=10)
Button(self.root, text="Browse", command=self.browse_source).grid(row=0, column=2, padx=10, pady=10)
Label(self.root, text="Target Directory:").grid(row=1, column=0, padx=10, pady=10)
self.target_entry = Entry(self.root, width=50)
self.target_entry.grid(row=1, column=1, padx=10, pady=10)
Button(self.root, text="Browse", command=self.browse_target).grid(row=1, column=2, padx=10, pady=10)
Button(self.root, text="Synchronize", command=self.synchronize).grid(row=2, column=0, columnspan=3, pady=20)
def browse_source(self):
"""Browse for the source directory."""
self.source_dir = filedialog.askdirectory()
self.source_entry.delete(0, "end")
self.source_entry.insert(0, self.source_dir)
def browse_target(self):
"""Browse for the target directory."""
self.target_dir = filedialog.askdirectory()
self.target_entry.delete(0, "end")
self.target_entry.insert(0, self.target_dir)
def synchronize(self):
"""Synchronize files between the source and target directories."""
if not self.source_dir or not self.target_dir:
messagebox.showerror("Error", "Both source and target directories must be selected.")
return
if not os.path.exists(self.source_dir):
messagebox.showerror("Error", "Source directory does not exist.")
return
if not os.path.exists(self.target_dir):
os.makedirs(self.target_dir)
actions = []
# Copy new and updated files
for root, dirs, files in os.walk(self.source_dir):
relative_path = os.path.relpath(root, self.source_dir)
target_root = os.path.join(self.target_dir, relative_path)
if not os.path.exists(target_root):
os.makedirs(target_root)
for file in files:
source_file = os.path.join(root, file)
target_file = os.path.join(target_root, file)
if not os.path.exists(target_file) or os.path.getmtime(source_file) > os.path.getmtime(target_file):
shutil.copy2(source_file, target_file)
actions.append(f"Copied: {source_file} -> {target_file}")
# Delete files that no longer exist in the source directory
for root, dirs, files in os.walk(self.target_dir):
relative_path = os.path.relpath(root, self.target_dir)
source_root = os.path.join(self.source_dir, relative_path)
for file in files:
target_file = os.path.join(root, file)
source_file = os.path.join(source_root, file)
if not os.path.exists(source_file):
os.remove(target_file)
actions.append(f"Deleted: {target_file}")
# Show summary
if actions:
messagebox.showinfo("Synchronization Complete", "\n".join(actions))
else:
messagebox.showinfo("Synchronization Complete", "No changes were made.")
def main():
root = Tk()
app = FileSynchronizationTool(root)
root.mainloop()
if __name__ == "__main__":
main()
"""
File Synchronization Tool
A tool to synchronize files between two directories with the following features:
- Detect and copy new or updated files
- Delete files that no longer exist in the source directory
- Provide a summary of synchronization actions
"""
import os
import shutil
from tkinter import Tk, Label, Entry, Button, messagebox, filedialog
class FileSynchronizationTool:
def __init__(self, root):
self.root = root
self.root.title("File Synchronization Tool")
self.source_dir = ""
self.target_dir = ""
self.setup_ui()
def setup_ui(self):
"""Set up the user interface."""
Label(self.root, text="Source Directory:").grid(row=0, column=0, padx=10, pady=10)
self.source_entry = Entry(self.root, width=50)
self.source_entry.grid(row=0, column=1, padx=10, pady=10)
Button(self.root, text="Browse", command=self.browse_source).grid(row=0, column=2, padx=10, pady=10)
Label(self.root, text="Target Directory:").grid(row=1, column=0, padx=10, pady=10)
self.target_entry = Entry(self.root, width=50)
self.target_entry.grid(row=1, column=1, padx=10, pady=10)
Button(self.root, text="Browse", command=self.browse_target).grid(row=1, column=2, padx=10, pady=10)
Button(self.root, text="Synchronize", command=self.synchronize).grid(row=2, column=0, columnspan=3, pady=20)
def browse_source(self):
"""Browse for the source directory."""
self.source_dir = filedialog.askdirectory()
self.source_entry.delete(0, "end")
self.source_entry.insert(0, self.source_dir)
def browse_target(self):
"""Browse for the target directory."""
self.target_dir = filedialog.askdirectory()
self.target_entry.delete(0, "end")
self.target_entry.insert(0, self.target_dir)
def synchronize(self):
"""Synchronize files between the source and target directories."""
if not self.source_dir or not self.target_dir:
messagebox.showerror("Error", "Both source and target directories must be selected.")
return
if not os.path.exists(self.source_dir):
messagebox.showerror("Error", "Source directory does not exist.")
return
if not os.path.exists(self.target_dir):
os.makedirs(self.target_dir)
actions = []
# Copy new and updated files
for root, dirs, files in os.walk(self.source_dir):
relative_path = os.path.relpath(root, self.source_dir)
target_root = os.path.join(self.target_dir, relative_path)
if not os.path.exists(target_root):
os.makedirs(target_root)
for file in files:
source_file = os.path.join(root, file)
target_file = os.path.join(target_root, file)
if not os.path.exists(target_file) or os.path.getmtime(source_file) > os.path.getmtime(target_file):
shutil.copy2(source_file, target_file)
actions.append(f"Copied: {source_file} -> {target_file}")
# Delete files that no longer exist in the source directory
for root, dirs, files in os.walk(self.target_dir):
relative_path = os.path.relpath(root, self.target_dir)
source_root = os.path.join(self.source_dir, relative_path)
for file in files:
target_file = os.path.join(root, file)
source_file = os.path.join(source_root, file)
if not os.path.exists(source_file):
os.remove(target_file)
actions.append(f"Deleted: {target_file}")
# Show summary
if actions:
messagebox.showinfo("Synchronization Complete", "\n".join(actions))
else:
messagebox.showinfo("Synchronization Complete", "No changes were made.")
def main():
root = Tk()
app = FileSynchronizationTool(root)
root.mainloop()
if __name__ == "__main__":
main()
Run it
C:\Users\Your Name\file-sync-tool> python file_synchronization_tool.py
# Browse for a source and target folder, then click Synchronize.
# A summary dialog lists every file copied or deleted.C:\Users\Your Name\file-sync-tool> python file_synchronization_tool.py
# Browse for a source and target folder, then click Synchronize.
# A summary dialog lists every file copied or deleted.Step-by-Step Explanation
1. Walk the source tree
for root, dirs, files in os.walk(self.source_dir):
relative_path = os.path.relpath(root, self.source_dir)
target_root = os.path.join(self.target_dir, relative_path)
if not os.path.exists(target_root):
os.makedirs(target_root)for root, dirs, files in os.walk(self.source_dir):
relative_path = os.path.relpath(root, self.source_dir)
target_root = os.path.join(self.target_dir, relative_path)
if not os.path.exists(target_root):
os.makedirs(target_root)os.walkos.walk yields every subdirectory and its files. relpathrelpath turns an absolute source path into a path relative to the source root, which you then re-root onto the target — that’s how nested folder structure is preserved on the other side.
2. Copy new or changed files
if not os.path.exists(target_file) or os.path.getmtime(source_file) > os.path.getmtime(target_file):
shutil.copy2(source_file, target_file)
actions.append(f"Copied: {source_file} -> {target_file}")if not os.path.exists(target_file) or os.path.getmtime(source_file) > os.path.getmtime(target_file):
shutil.copy2(source_file, target_file)
actions.append(f"Copied: {source_file} -> {target_file}")Two conditions trigger a copy: the file is missing at the target, or the source is newer (getmtimegetmtime is the last-modified timestamp). shutil.copy2shutil.copy2 copies the contents and metadata (timestamps, permissions) — plain copycopy would reset the modification time and break the next comparison.
3. Delete stragglers
for root, dirs, files in os.walk(self.target_dir):
...
if not os.path.exists(source_file):
os.remove(target_file)
actions.append(f"Deleted: {target_file}")for root, dirs, files in os.walk(self.target_dir):
...
if not os.path.exists(source_file):
os.remove(target_file)
actions.append(f"Deleted: {target_file}")This second walk — over the target — removes anything that no longer exists at the source, making the target a true mirror. This is the dangerous part: a wrong source folder will wipe the target. The dry-run below is your seatbelt.
4. Report what happened
if actions:
messagebox.showinfo("Synchronization Complete", "\n".join(actions))
else:
messagebox.showinfo("Synchronization Complete", "No changes were made.")if actions:
messagebox.showinfo("Synchronization Complete", "\n".join(actions))
else:
messagebox.showinfo("Synchronization Complete", "No changes were made.")Always tell the user exactly what changed. Silent file operations destroy trust (and data).
Add a Dry-Run Preview
The single most important upgrade for any destructive tool — compute the actions, show them, act only on confirmation:
def plan(self):
"""Return the list of actions WITHOUT performing them."""
actions = []
# ...same comparison logic, but append strings instead of copying/deleting...
return actions
def synchronize(self):
actions = self.plan()
preview = "\n".join(actions) or "No changes."
if messagebox.askyesno("Confirm Sync", f"{preview}\n\nApply these changes?"):
self.apply(actions)def plan(self):
"""Return the list of actions WITHOUT performing them."""
actions = []
# ...same comparison logic, but append strings instead of copying/deleting...
return actions
def synchronize(self):
actions = self.plan()
preview = "\n".join(actions) or "No changes."
if messagebox.askyesno("Confirm Sync", f"{preview}\n\nApply these changes?"):
self.apply(actions)Now the user sees “Delete: report.docx” before it’s gone.
Detect Changes by Content, Not Just Time
getmtimegetmtime can lie — a restored backup may have an old timestamp but new content. Hash the files instead:
import hashlib
def file_hash(path, chunk=65536):
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(chunk), b""):
h.update(block)
return h.hexdigest()
def changed(src, dst):
return not os.path.exists(dst) or file_hash(src) != file_hash(dst)import hashlib
def file_hash(path, chunk=65536):
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(chunk), b""):
h.update(block)
return h.hexdigest()
def changed(src, dst):
return not os.path.exists(dst) or file_hash(src) != file_hash(dst)Slower, but it never misses an edit and never copies an unchanged file. Read in chunks so large files don’t blow up memory.
Two-Way Sync (Carefully)
True two-way sync keeps the newest version of each file on both sides and is genuinely hard — you need to track deletions vs. creations, usually with a stored snapshot of the last sync. Start one-way; only attempt two-way once you’ve added a manifest of known files.
Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
| Target files wiped unexpectedly | Wrong source folder + delete pass | Always run a dry-run and confirm first |
| Unchanged files keep re-copying | Used copycopy (resets mtime) | Use shutil.copy2shutil.copy2 to preserve timestamps |
| Edits not detected | Same mtime, different content | Compare with a content hash |
| App freezes on big folders | Sync on the main thread | Run on a background thread; show progress |
PermissionErrorPermissionError mid-sync | File open/locked, or no rights | Catch per-file and log; continue the rest |
| Empty subfolders not mirrored | Only files were handled | Create dirs during the walk (as the code does) |
Variations to Try
- Dry-run by default — make preview mandatory, apply on confirm.
- Exclude patterns — skip
.git.git,node_modulesnode_modules,*.tmp*.tmpvia globs. - Progress bar — show files-done / total during long syncs.
- Scheduling — run sync every N minutes with
afterafteror a cron job. - Conflict handling — when both sides changed, keep both with a suffix.
- Cloud target — sync to S3 or Google Drive via their SDKs.
- Versioned backups — move deleted files to a
.trash.trashfolder instead of removing.
Real-World Applications
- Backup software — the core of every incremental backup tool.
- Deployment — pushing build artifacts to a server directory.
- Photo/media management — mirroring a camera card to a library.
- Dev environments — keeping a working copy in sync with a share.
Educational Value
- Filesystem traversal —
os.walkos.walk,relpathrelpath,makedirsmakedirs. - Change detection — mtime vs. content hashing trade-offs.
- Safe destructive operations — the preview-then-commit pattern.
- Metadata-aware copying — why
copy2copy2matters.
Next Steps
- Make the dry-run preview mandatory before any delete.
- Switch to hash-based change detection.
- Add exclude patterns and a progress bar.
- Move deletions to a
.trash.trashfolder for recoverability.
Conclusion
You built a directory mirror that copies new and changed files and prunes the rest, then made it safe with a dry-run preview and accurate with content hashing. Handling files — especially deleting them — demands the caution you practiced here: show the plan, confirm, preserve metadata, log everything. That discipline is what turns a risky script into a tool you’d trust with real data. Full source on GitHub. Explore more file-handling projects on Python Central Hub.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
