Skip to content

Automated File Mover

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/Documents/, Images/Images/, Videos/Videos/, Archives/Archives/, Code/Code/). Then we make it robust: conflict resolution for duplicate names, logging, dry-run mode, recursive scanning, real-time watching with watchdogwatchdog, and a scheduled run via cron / Task Scheduler.

You will leave with:

  • Clean use of pathlibpathlib 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/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-organizerfile-organizer.
  2. Inside, create automatedfilemover.pyautomatedfilemover.py.
  3. Create source/source/ and destination/destination/ subfolders. Drop a few test files into source/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/"
 
# 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)
            
# End of File
print("Move Complete")
File Mover
# Automated File Mover
 
import os
import shutil
 
# Set the source and destination directories
source = os.getcwd() + "/source/"
destination = os.getcwd() + "/destination/"
 
# 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)
            
# End of File
print("Move Complete")

Run it

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

Step-by-Step Explanation

1. Imports — switch to pathlibpathlib

automatedfilemover.py
import shutil
from pathlib import Path
automatedfilemover.py
import shutil
from pathlib import Path

The original used osos + string concatenation (source + filesource + file). pathlibpathlib:

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

2. The simple version

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.")
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.suffixfile.suffix includes the leading dot (.pdf.pdf), and .lower().lower() normalizes case.
  • DEST / file.nameDEST / file.name joins paths properly.
  • mkdir(exist_ok=True)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:

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))
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.pdfreport.pdf lands in destination/Documents/destination/Documents/, photo.pngphoto.png in destination/Images/destination/Images/, etc.

Conflict Resolution

What if destination/Images/photo.pngdestination/Images/photo.png already exists? The naïve shutil.moveshutil.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
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.pngphoto.png collides → next save becomes photo_1.pngphoto_1.png, then photo_2.pngphoto_2.png, and so on. No data lost.

Dry-Run Mode

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)))
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 = TrueDRY_RUN = True, review the plan, then run again with it FalseFalse. 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:

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

Real-Time Watcher

For continuous organization, use watchdogwatchdog:

install
pip install 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()
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/source/ is sorted instantly. Run this as a background service and your Downloads folder organizes itself.

Common Mistakes

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

Variations to Try

1. By date

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

2. By size

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

3. By name regex

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

4. Recursive scan

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

5. Undo log

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

6. Config file

Move CATEGORIESCATEGORIES into config.yamlconfig.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())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:

cron
*/15 * * * * /usr/bin/python /path/to/automatedfilemover.py
cron
*/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/Trash/ folder with a 30-day cleanup rule rather than deleting.

12. AI sort

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

Architecture Path

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

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/dist/, coverage/coverage/, logs/logs/.
  • Mail sorting — combined with imaplibimaplib, sort attachments into folders.

Security Considerations

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

Educational Value

  • pathlibpathlib — the modern way to do file paths.
  • shutilshutil — 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 pathlibpathlib.
  • Add typed sub-folders by category.
  • Add conflict resolution with unique_pathunique_path.
  • Add DRY_RUNDRY_RUN mode and use it before every real move.
  • Schedule with cron / Task Scheduler.
  • Switch to a watchdogwatchdog daemon for real-time organization.

Conclusion

You wrote a real productivity tool — the script can run from your Downloads/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.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did