Python Central HubSign in

Python Projects / Beginner

Automated File Mover

Build a Python script that auto-sorts files into folders by extension. Learn pathlib, conflict handling, watchdog for real-time monitoring, dry-run mode, and a path to a full file-organizer service.

Abstract

The Downloads folder is the universal Python beginner's enemy. Hundreds of mixed files — receipts, screenshots, installers, random PDFs — piled in one place. In this project you build a script that scans a source directory and routes each file to a typed destination folder (Documents/, Images/, Videos/, Archives/, Code/). Then we make it robust: conflict resolution for duplicate names, logging, dry-run mode, recursive scanning, real-time watching with watchdog, and a scheduled run via cron / Task Scheduler.

You will leave with:

  • Clean use of pathlib instead of string concatenation.
  • A reusable file-type registry.
  • Patterns for safely renaming on conflict.
  • Dry-run mode — the difference between a careful script and a destructive one.
  • A real-time watcher for Downloads/.

Prerequisites

  • Python 3.6 or above.
  • A text editor or IDE.
  • Comfort with file paths.

Getting Started

Create the project

  1. Create folder file-organizer.
  2. Inside, create automatedfilemover.py.
  3. Create source/ and destination/ subfolders. Drop a few test files into source/.

Write the code

File MoverSource
File Mover
# Automated File Mover

import os
import shutil

# Set the source and destination directories
source = os.getcwd() + "/source/"
destination = os.getcwd() + "/destination/"

# Both directories have to exist before either is used. `os.listdir` on a
# missing path raises FileNotFoundError, and `shutil.move` into a missing one
# fails halfway through -- after some files have already moved, which is the
# worse of the two failures.
os.makedirs(source, exist_ok=True)
os.makedirs(destination, exist_ok=True)

# Give the demo something to move, so a first run shows the behaviour rather
# than an empty directory listing.
if not os.listdir(source):
    for name in ("notes.txt", "report.pdf", "photo.jpg", "archive.zip"):
        with open(os.path.join(source, name), "w", encoding="utf-8") as f:
            f.write("demo file\n")
    print(f"source was empty, so 4 sample files were written to {source}")

# Get the list of files in the source directory
files = os.listdir(source)

# Select File Types to Move
file_types = ["txt", "pdf", "png", "jpg", "jpeg"]

# Move the files to the destination directory
for file in files:
    for file_type in file_types:
        if file.endswith(file_type):
            shutil.move(source + file, destination + file)
            print("Moved " + file + " to " + destination + file)
            
left = os.listdir(source)
print(f"\nMove Complete: {len(files) - len(left)} moved, "
      f"{len(left)} left behind")
if left:
    print(f"  left in place: {', '.join(sorted(left))}")
    print(f"  (the filter only moves {', '.join(file_types)})")

Run it

cmd
C:\Users\Your Name\file-organizer> python automatedfilemover.py
Moved document.txt → destination/document.txt
Moved photo.png → destination/photo.png
Moved report.pdf → destination/report.pdf
Move Complete

Step-by-Step Explanation

1. Imports — switch to pathlib

Python
import shutil
from pathlib import Path

The original used os + string concatenation (source + file). pathlib:

  • Is OS-agnostic (/ works everywhere).
  • Returns Path objects with useful methods (.suffix, .stem, .exists()).
  • Catches errors at object construction, not at use.

2. The simple version

Python
SOURCE = Path("source")
DEST = Path("destination")
WANTED_EXTENSIONS = {".txt", ".pdf", ".png", ".jpg", ".jpeg"}
 
DEST.mkdir(exist_ok=True)
for file in SOURCE.iterdir():
    if file.is_file() and file.suffix.lower() in WANTED_EXTENSIONS:
        target = DEST / file.name
        shutil.move(str(file), str(target))
        print(f"Moved {file.name} → {target}")
print("Move complete.")
  • file.suffix includes the leading dot (.pdf), and .lower() normalizes case.
  • DEST / file.name joins paths properly.
  • mkdir(exist_ok=True) does not error if the folder already exists.

Sort Into Typed Sub-folders

The naïve version dumps everything into one destination. The interesting version routes by file type:

Python
CATEGORIES = {
    "Documents": {".pdf", ".txt", ".doc", ".docx", ".odt", ".rtf"},
    "Spreadsheets": {".xls", ".xlsx", ".csv", ".ods"},
    "Images":    {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"},
    "Videos":    {".mp4", ".mov", ".avi", ".mkv", ".webm"},
    "Audio":     {".mp3", ".wav", ".ogg", ".flac", ".m4a"},
    "Archives":  {".zip", ".rar", ".7z", ".tar", ".gz"},
    "Code":      {".py", ".js", ".ts", ".html", ".css", ".java", ".cpp"},
    "Installers":{".exe", ".msi", ".dmg", ".deb", ".rpm", ".pkg"},
}
 
def category_for(suffix: str) -> str:
    for cat, exts in CATEGORIES.items():
        if suffix in exts:
            return cat
    return "Other"
 
for file in SOURCE.iterdir():
    if not file.is_file(): continue
    cat = category_for(file.suffix.lower())
    target_dir = DEST / cat
    target_dir.mkdir(parents=True, exist_ok=True)
    shutil.move(str(file), str(target_dir / file.name))

Now report.pdf lands in destination/Documents/, photo.png in destination/Images/, etc.

Conflict Resolution

What if destination/Images/photo.png already exists? The naïve shutil.move overwrites it silently — a quiet way to lose files.

Python
def unique_path(target: Path) -> Path:
    if not target.exists(): return target
    stem, suffix, parent = target.stem, target.suffix, target.parent
    counter = 1
    while True:
        candidate = parent / f"{stem}_{counter}{suffix}"
        if not candidate.exists():
            return candidate
        counter += 1

Now photo.png collides → next save becomes photo_1.png, then photo_2.png, and so on. No data lost.

Dry-Run Mode

Always preview before destructive changes:

Python
DRY_RUN = True              # toggle this
 
for file in SOURCE.iterdir():
    if not file.is_file(): continue
    target = DEST / category_for(file.suffix.lower()) / file.name
    if DRY_RUN:
        print(f"[dry-run] Would move {file.name} → {target}")
    else:
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(file), str(unique_path(target)))

Run once with DRY_RUN = True, review the plan, then run again with it False. This single discipline prevents almost every accidental-data-loss disaster.

Logging

Print is fine for ad-hoc runs. For a scheduled service, write a log file:

Python
import logging
logging.basicConfig(
    filename="file_mover.log",
    level=logging.INFO,
    format="%(asctime)s  %(message)s",
)
logging.info("Moved %s → %s", file.name, target)

Each run appends timestamped lines you can review later.

Real-Time Watcher

For continuous organization, use watchdog:

Shell
pip install watchdog
Python
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
import time
 
class Mover(FileSystemEventHandler):
    def on_created(self, event):
        if event.is_directory: return
        time.sleep(1)                       # wait for write to finish
        organize_one(Path(event.src_path))
 
obs = Observer()
obs.schedule(Mover(), str(SOURCE), recursive=False)
obs.start()
try:
    while True: time.sleep(60)
except KeyboardInterrupt:
    obs.stop()
obs.join()

Now every file dropped into source/ is sorted instantly. Run this as a background service and your Downloads folder organizes itself.

Common Mistakes

ProblemCauseFix
Files overwritten silentlyshutil.move to an existing pathUse unique_path()
PermissionErrorFile still being downloaded/usedTry a second time after a delay
Hidden files movedGlob included .DS_Store, desktop.iniSkip file.name.startswith(".")
shutil across drives slowMove = copy + delete on cross-driveAcceptable; use shutil.move for portability
Recursive infinite loopdest is inside sourceValidate that one is not a subpath of the other
Source folder not existsTypo in pathassert SOURCE.is_dir(), f"missing {SOURCE}" early

Variations to Try

1. By date

Sort into Year/Month/Day folders using datetime.fromtimestamp(file.stat().st_mtime).

2. By size

Bucket into Small/, Medium/, Large/ (e.g., < 1 MB, < 100 MB, ≥ 100 MB).

3. By name regex

Receipts (receipt_*.pdf), screenshots (Screenshot_*.png), invoices — handled by pattern, not extension.

4. Recursive scan

SOURCE.rglob("*") to handle nested directories.

5. Undo log

Append every move as (source, destination) to moves.log. An undo.py script reads the log and reverses them.

6. Config file

Move CATEGORIES into config.yaml so the user can adjust without editing Python.

7. GUI

Tkinter window with source/destination pickers, a "Preview" button (dry-run), and "Move" button.

8. Cloud sync

Move source = local Downloads, destination = synced cloud folder.

9. Duplicate detection

Hash files with hashlib.md5(file.read_bytes()). If the hash already exists in destination, delete the duplicate instead of moving it.

10. Cron / Task Scheduler

Run every 15 minutes:

Shell
*/15 * * * * /usr/bin/python /path/to/automatedfilemover.py

Or on Windows: Task Scheduler → Create Basic Task → Daily / 15-min interval.

11. Trash, not delete

For "Other" category files, move to a Trash/ folder with a 30-day cleanup rule rather than deleting.

12. AI sort

For unclear files, OCR / read the content (PDFs with pypdf) and route by detected topic.

Architecture Path

Python
Script run on demand (you are here)
   ↓
Logging + dry-run
   ↓
watchdog real-time daemon
   ↓
systemd service / Windows service
   ↓
GUI front-end on top
   ↓
Cross-device sync rules

Common Use Cases

  • Personal cleanup — Downloads, Desktop.
  • Photo import — phone-dumped pictures sorted into year/month.
  • Server intake — incoming uploads routed by user / type.
  • Build artifacts — sort by extension into dist/, coverage/, logs/.
  • Mail sorting — combined with imaplib, sort attachments into folders.

Security Considerations

  • Path safety — refuse moves where target escapes DEST (target.resolve().is_relative_to(DEST.resolve())).
  • Symbolic links — by default iterdir() follows them; check file.is_symlink() if that worries you.
  • Permissions — wrap shutil.move in try/except OSError and log failures.
  • Concurrent execution — two instances racing on the same folder corrupts state; use a lock file.

Educational Value

  • pathlib — the modern way to do file paths.
  • shutil — file copy/move/delete, cross-platform.
  • Defensive programming — dry-run, conflict resolution, logging.
  • Real-time programming — event-driven watchers.
  • Service operation — cron, systemd, Windows Task Scheduler.

Next Steps

  • Refactor with pathlib.
  • Add typed sub-folders by category.
  • Add conflict resolution with unique_path.
  • Add DRY_RUN mode and use it before every real move.
  • Schedule with cron / Task Scheduler.
  • Switch to a watchdog daemon for real-time organization.

What it produces

Running the file exactly as it ships takes 0.1 s and prints:

Text
source was empty, so 4 sample files were written to C:\Users\Zimyo\AppData\Local\Temp\pch-project-6256yzfq/source/
Moved notes.txt to C:\Users\Zimyo\AppData\Local\Temp\pch-project-6256yzfq/destination/notes.txt
Moved photo.jpg to C:\Users\Zimyo\AppData\Local\Temp\pch-project-6256yzfq/destination/photo.jpg
Moved report.pdf to C:\Users\Zimyo\AppData\Local\Temp\pch-project-6256yzfq/destination/report.pdf
 
Move Complete: 3 moved, 1 left behind
  left in place: archive.zip
  (the filter only moves txt, pdf, png, jpg, jpeg)

Conclusion

You wrote a real productivity tool — the script can run from your Downloads/ folder and stop entropy in its tracks. The patterns (path objects, conflict resolution, dry-run, logging) reappear in every batch-processing script you will ever write. Full source on GitHub. Find more automation projects on Python Central Hub.

Was this page helpful?

Let us know how we did