Python Central HubSign in

Python Projects / Intermediate

File Synchronization Tool

Build a Tkinter file sync tool in Python that mirrors one directory into another — copying new and changed files, deleting stragglers — then add a dry-run preview, hash-based change detection, two-way sync, and a progress log.

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.walk traverses a directory tree, and os.path.relpath preserves structure.
  • Why getmtime comparison decides what's "newer".
  • Why shutil.copy2 (not copy) 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 os and shutil 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-tool.
  2. Inside it, create file_synchronization_tool.py.
  3. Make two test folders (e.g. src_test, dst_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()

Run it

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

How it fits together

Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.

diagram Diagram mermaid

Step-by-Step Explanation

1. Walk the source tree

Python
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.walk yields every subdirectory and its files. relpath 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

Python
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 (getmtime is the last-modified timestamp). shutil.copy2 copies the contents and metadata (timestamps, permissions) — plain copy would reset the modification time and break the next comparison.

3. Delete stragglers

Python
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

Python
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:

Python
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

getmtime can lie — a restored backup may have an old timestamp but new content. Hash the files instead:

Python
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 copy (resets mtime)Use shutil.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
PermissionError 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, node_modules, *.tmp via globs.
  3. Progress bar — show files-done / total during long syncs.
  4. Scheduling — run sync every N minutes with after 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 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 traversal — os.walk, relpath, makedirs.
  • Change detection — mtime vs. content hashing trade-offs.
  • Safe destructive operations — the preview-then-commit pattern.
  • Metadata-aware copying — why copy2 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 folder for recoverability.

Checkpoint exercise

This checkpoint practices the planning step of a sync tool: compare two folders by content hash and decide what to copy, update and delete.

exerciseYour turn

Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.

Output
Press Run to see what your code prints.

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.

Was this page helpful?

Let us know how we did