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
pathlibpathlibinstead 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
- Create folder
file-organizerfile-organizer. - Inside, create
automatedfilemover.pyautomatedfilemover.py. - Create
source/source/anddestination/destination/subfolders. Drop a few test files intosource/source/.
Write the code
File Mover
Source# 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")# 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
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 CompleteC:\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
1. Imports — switch to pathlibpathlib
import shutil
from pathlib import Pathimport shutil
from pathlib import PathThe original used osos + string concatenation (source + filesource + file). pathlibpathlib:
- Is OS-agnostic (
//works everywhere). - Returns
PathPathobjects with useful methods (.suffix.suffix,.stem.stem,.exists().exists()). - Catches errors at object construction, not at use.
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.")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.suffixincludes the leading dot (.pdf.pdf), and.lower().lower()normalizes case.DEST / file.nameDEST / file.namejoins 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 = {
"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 = {
"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.
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 += 1def 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.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:
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)))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:
import logging
logging.basicConfig(
filename="file_mover.log",
level=logging.INFO,
format="%(asctime)s %(message)s",
)
logging.info("Moved %s → %s", file.name, target)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:
pip install watchdogpip 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()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
| Problem | Cause | Fix |
|---|---|---|
| Files overwritten silently | shutil.moveshutil.move to an existing path | Use unique_path()unique_path() |
PermissionErrorPermissionError | File still being downloaded/used | Try a second time after a delay |
| Hidden files moved | Glob included .DS_Store.DS_Store, desktop.inidesktop.ini | Skip file.name.startswith(".")file.name.startswith(".") |
shutilshutil across drives slow | Move = copy + delete on cross-drive | Acceptable; use shutil.moveshutil.move for portability |
| Recursive infinite loop | destdest is inside sourcesource | Validate that one is not a subpath of the other |
| Source folder not exists | Typo in path | assert 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:
*/15 * * * * /usr/bin/python /path/to/automatedfilemover.py*/15 * * * * /usr/bin/python /path/to/automatedfilemover.pyOr 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
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 rulesScript 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
- 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; checkfile.is_symlink()file.is_symlink()if that worries you. - Permissions — wrap
shutil.moveshutil.moveintry/except OSErrortry/except OSErrorand 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_RUNmode and use it before every real move. - Schedule with cron / Task Scheduler.
- Switch to a
watchdogwatchdogdaemon 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 coffeeWas this page helpful?
Let us know how we did
