Skip to content

Automated File Mover

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/.
  • Python 3.6 or above.
  • A text editor or IDE.
  • Comfort with file paths.
  1. Create folder file-organizer.
  2. Inside, create automatedfilemover.py.
  3. Create source/ and destination/ subfolders. Drop a few test files into source/.
File Mover pch.viewSource
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)})")
command
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
automatedfilemover.py
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.
automatedfilemover.py
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.

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

categories.py
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.

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

conflict.py
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.

Always preview before destructive changes:

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

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

logging.py
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.

For continuous organization, use watchdog:

install
pip install watchdog
watch.py
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.

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

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

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

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

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

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

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

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

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

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

Run every 15 minutes:

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

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

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

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

text
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
  • 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.
  • 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.
  • 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.
  • 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.

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

python automatedfilemover.py
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)

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.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading