Automated File Mover
Abstract
Section titled “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
pathlibinstead 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
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE.
- Comfort with file paths.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create folder
file-organizer. - Inside, create
automatedfilemover.py. - Create
source/anddestination/subfolders. Drop a few test files intosource/.
Write the code
Section titled “Write the code”File Mover
pch.viewSource# 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
Section titled “Run it”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 CompleteStep-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Imports — switch to pathlib
Section titled “1. Imports — switch to pathlib”import shutil
from pathlib import PathThe original used os + string concatenation (source + file). pathlib:
- Is OS-agnostic (
/works everywhere). - Returns
Pathobjects with useful methods (.suffix,.stem,.exists()). - Catches errors at object construction, not at use.
2. The simple version
Section titled “2. The simple version”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.suffixincludes the leading dot (.pdf), and.lower()normalizes case.DEST / file.namejoins paths properly.mkdir(exist_ok=True)does not error if the folder already exists.
Sort Into Typed Sub-folders
Section titled “Sort Into Typed Sub-folders”The naïve version dumps everything into one destination. The interesting version routes by file type:
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
Section titled “Conflict Resolution”What if destination/Images/photo.png already exists? The naïve shutil.move overwrites it silently — a quiet way to lose files.
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 += 1Now photo.png collides → next save becomes photo_1.png, then photo_2.png, and so on. No data lost.
Dry-Run Mode
Section titled “Dry-Run Mode”Always preview before destructive changes:
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
Section titled “Logging”Print is fine for ad-hoc runs. For a scheduled service, write a log file:
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
Section titled “Real-Time Watcher”For continuous organization, use watchdog:
pip install watchdogfrom 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
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Files overwritten silently | shutil.move to an existing path | Use unique_path() |
PermissionError | File still being downloaded/used | Try a second time after a delay |
| Hidden files moved | Glob included .DS_Store, desktop.ini | Skip file.name.startswith(".") |
shutil across drives slow | Move = copy + delete on cross-drive | Acceptable; use shutil.move for portability |
| Recursive infinite loop | dest is inside source | Validate that one is not a subpath of the other |
| Source folder not exists | Typo in path | assert SOURCE.is_dir(), f"missing {SOURCE}" early |
Variations to Try
Section titled “Variations to Try”1. By date
Section titled “1. By date”Sort into Year/Month/Day folders using datetime.fromtimestamp(file.stat().st_mtime).
2. By size
Section titled “2. By size”Bucket into Small/, Medium/, Large/ (e.g., < 1 MB, < 100 MB, ≥ 100 MB).
3. By name regex
Section titled “3. By name regex”Receipts (receipt_*.pdf), screenshots (Screenshot_*.png), invoices — handled by pattern, not extension.
4. Recursive scan
Section titled “4. Recursive scan”SOURCE.rglob("*") to handle nested directories.
5. Undo log
Section titled “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
Section titled “6. Config file”Move CATEGORIES into config.yaml so the user can adjust without editing Python.
7. GUI
Section titled “7. GUI”Tkinter window with source/destination pickers, a “Preview” button (dry-run), and “Move” button.
8. Cloud sync
Section titled “8. Cloud sync”Move source = local Downloads, destination = synced cloud folder.
9. Duplicate detection
Section titled “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
Section titled “10. Cron / Task Scheduler”Run every 15 minutes:
*/15 * * * * /usr/bin/python /path/to/automatedfilemover.pyOr on Windows: Task Scheduler → Create Basic Task → Daily / 15-min interval.
11. Trash, not delete
Section titled “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
Section titled “12. AI sort”For unclear files, OCR / read the content (PDFs with pypdf) and route by detected topic.
Architecture Path
Section titled “Architecture Path”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 rulesCommon Use Cases
Section titled “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
Section titled “Security Considerations”- Path safety — refuse moves where target escapes
DEST(target.resolve().is_relative_to(DEST.resolve())). - Symbolic links — by default
iterdir()follows them; checkfile.is_symlink()if that worries you. - Permissions — wrap
shutil.moveintry/except OSErrorand log failures. - Concurrent execution — two instances racing on the same folder corrupts state; use a lock file.
Educational Value
Section titled “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
Section titled “Next Steps”- Refactor with
pathlib. - Add typed sub-folders by category.
- Add conflict resolution with
unique_path. - Add
DRY_RUNmode and use it before every real move. - Schedule with cron / Task Scheduler.
- Switch to a
watchdogdaemon for real-time organization.
What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.1 s and prints:
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
Section titled “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.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading