Skip to content

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.walk traverses a directory tree, and os.path.relpathos.path.relpath preserves structure.
  • Why getmtimegetmtime comparison decides what’s “newer”.
  • Why shutil.copy2shutil.copy2 (not copycopy) 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 osos and shutilshutil from the standard library.
  • Two test folders with throwaway files — never debug a sync tool on real data.

Getting Started

Create the project

  1. Create a folder named file-sync-toolfile-sync-tool.
  2. Inside it, create file_synchronization_tool.pyfile_synchronization_tool.py.
  3. Make two test folders (e.g. src_testsrc_test, dst_testdst_test) with a few dummy files.

Write the code

file_synchronization_tool.pySource
file_synchronization_tool.py
"""
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.py
"""
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

command
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.
command
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

file_synchronization_tool.py
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)
file_synchronization_tool.py
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

file_synchronization_tool.py
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}")
file_synchronization_tool.py
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

file_synchronization_tool.py
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}")
file_synchronization_tool.py
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

file_synchronization_tool.py
if actions:
    messagebox.showinfo("Synchronization Complete", "\n".join(actions))
else:
    messagebox.showinfo("Synchronization Complete", "No changes were made.")
file_synchronization_tool.py
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:

dryrun.py
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)
dryrun.py
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:

hashing.py
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)
hashing.py
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

ProblemCauseFix
Target files wiped unexpectedlyWrong source folder + delete passAlways run a dry-run and confirm first
Unchanged files keep re-copyingUsed copycopy (resets mtime)Use shutil.copy2shutil.copy2 to preserve timestamps
Edits not detectedSame mtime, different contentCompare with a content hash
App freezes on big foldersSync on the main threadRun on a background thread; show progress
PermissionErrorPermissionError mid-syncFile open/locked, or no rightsCatch per-file and log; continue the rest
Empty subfolders not mirroredOnly files were handledCreate dirs during the walk (as the code does)

Variations to Try

  1. Dry-run by default — make preview mandatory, apply on confirm.
  2. Exclude patterns — skip .git.git, node_modulesnode_modules, *.tmp*.tmp via globs.
  3. Progress bar — show files-done / total during long syncs.
  4. Scheduling — run sync every N minutes with afterafter or a cron job.
  5. Conflict handling — when both sides changed, keep both with a suffix.
  6. Cloud target — sync to S3 or Google Drive via their SDKs.
  7. Versioned backups — move deleted files to a .trash.trash folder 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 traversalos.walkos.walk, relpathrelpath, makedirsmakedirs.
  • Change detection — mtime vs. content hashing trade-offs.
  • Safe destructive operations — the preview-then-commit pattern.
  • Metadata-aware copying — why copy2copy2 matters.

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.trash folder 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 coffee

Was this page helpful?

Let us know how we did