Intermediate

Your Downloads folder is a graveyard. PDFs from three jobs ago, screenshots with names like image(47).png, ZIP archives you extracted months ago, and a dozen video files you swore you would watch. Every time you need to find something, you scroll past hundreds of unrelated files. Sound familiar? The good news is you can fix this permanently with about 50 lines of Python.

Python’s standard library gives you everything you need to build a real file organizer: pathlib for readable path manipulation, shutil for safe file moves, os for directory creation, and argparse for a proper command-line interface. No third-party packages required. The whole script runs on any machine with Python 3.6 or later installed.

In this article, we will build a desktop file organizer step by step. First, we will cover the Quick Example to get a feel for the approach. Then we will look at scanning directories with pathlib, moving files safely with shutil, organizing by file extension, and adding a CLI with dry-run mode. By the end you will have a production-ready script you can run on any folder — and set up as a scheduled task if you want it to run automatically.

Organizing a Folder: Quick Example

Here is the core idea in 25 lines. This script scans a target folder and moves every file into a subfolder named after its category (Images, Documents, Videos, and so on).

# quick_organizer.py
import shutil
from pathlib import Path

FOLDER = Path.home() / "Downloads"

EXT_MAP = {
    ".pdf": "Documents", ".docx": "Documents", ".txt": "Documents",
    ".jpg": "Images",    ".jpeg": "Images",    ".png": "Images", ".gif": "Images",
    ".mp4": "Videos",    ".mov": "Videos",     ".avi": "Videos",
    ".mp3": "Audio",     ".wav": "Audio",
    ".zip": "Archives",  ".tar": "Archives",   ".gz":  "Archives",
    ".py":  "Code",      ".js":  "Code",       ".html":"Code",
}

moved = 0
for item in FOLDER.iterdir():
    if not item.is_file():
        continue
    category = EXT_MAP.get(item.suffix.lower(), "Other")
    dest_dir = FOLDER / category
    dest_dir.mkdir(exist_ok=True)
    shutil.move(str(item), str(dest_dir / item.name))
    print(f"Moved: {item.name}  ->  {category}/")
    moved += 1

print(f"\nDone. {moved} file(s) organized.")

Output:

Moved: budget-2025.pdf  ->  Documents/
Moved: screenshot-2026-01.png  ->  Images/
Moved: project.zip  ->  Archives/
Moved: notes.txt  ->  Documents/

Done. 4 file(s) organized.

The key players are Path.iterdir() to list every item in the folder, item.suffix.lower() to get the file extension normalized to lowercase, and shutil.move() to relocate the file. The dest_dir.mkdir(exist_ok=True) call creates the category subfolder only if it does not already exist — so running the script twice is completely safe.

The sections below build this into a proper, reusable tool with a CLI, duplicate protection, dry-run mode, and a configurable extension map.

Python file organizer extension map sorted bins
shutil.move() doesn’t ask questions. It just moves.

What Is a File Organizer and Why Build One in Python?

A file organizer is a script that inspects the name or metadata of each file in a directory and moves it to a predefined location based on rules you define. The simplest rule is the one we use here: sort by file extension. An extension like .pdf tells you the file is a document; .mp4 tells you it is a video. More sophisticated organizers can read EXIF metadata from photos to sort by date, or inspect file contents for type detection — but extension-based sorting covers 90% of real-world use cases with zero added complexity.

Why Python? Because the standard library is batteries-included for this task. Compare your options:

ApproachSetupFlexibilityCross-platform
Python script (this article)No install neededFull controlYes
Folder rules in Windows ExplorerBuilt-in GUIVery limitedWindows only
Automator (macOS)Built-in GUIModeratemacOS only
Bash scriptNo installHigh (but verbose)Unix only

Python gives you the most flexibility with the least friction. Once written, you can run the same script on Windows, macOS, and Linux without changing a line.

Scanning Directories with pathlib

The pathlib module, introduced in Python 3.4, treats file paths as objects with useful methods instead of plain strings. For a file organizer, the two most important methods are Path.iterdir() — which lists everything in a single directory — and Path.rglob() — which searches recursively through nested subdirectories.

# scan_demo.py
from pathlib import Path

target = Path("/tmp/messy_folder")

# List only direct children (non-recursive)
print("Direct children:")
for item in target.iterdir():
    kind = "DIR " if item.is_dir() else "FILE"
    print(f"  [{kind}] {item.name}  (suffix: {repr(item.suffix)})")

Output:

Direct children:
  [FILE] report.pdf  (suffix: '.pdf')
  [FILE] Photo_2026.JPG  (suffix: '.JPG')
  [DIR ] old_projects  (suffix: '')
  [FILE] notes.txt  (suffix: '.txt')

Notice that item.suffix preserves the original case (e.g., '.JPG' not '.jpg'). Always call .lower() on it before looking it up in your extension map, otherwise .JPG and .jpg will be treated as different file types. The item.is_dir() check lets us skip subdirectories so we only process actual files.

For deep folder trees, swap iterdir() for rglob("*") and add an if item.is_file() check:

# recursive_scan.py
from pathlib import Path

target = Path("/tmp/messy_folder")

print("All files (recursive):")
for item in target.rglob("*"):
    if item.is_file():
        print(f"  {item.relative_to(target)}  ->  ext: {item.suffix.lower() or 'none'}")

Output:

All files (recursive):
  report.pdf  ->  ext: .pdf
  Photo_2026.JPG  ->  ext: .jpg
  notes.txt  ->  ext: .txt
  old_projects/draft.docx  ->  ext: .docx

item.relative_to(target) shows a clean path relative to the root folder rather than the full absolute path — useful for readable log output.

Python pathlib iterdir scanning folder tree for file extensions
item.suffix.lower() — because .JPG and .jpg are not the same type.

Moving Files Safely with shutil

Once you know where a file should go, shutil.move(src, dst) relocates it. The function accepts both string paths and Path objects. It handles the cross-device case automatically — if source and destination are on different drives, it copies then deletes rather than doing a raw rename that would fail.

# move_demo.py
import shutil
from pathlib import Path

src = Path("/tmp/messy_folder/report.pdf")
dst_dir = Path("/tmp/messy_folder/Documents")
dst_dir.mkdir(parents=True, exist_ok=True)

dst = dst_dir / src.name
shutil.move(str(src), str(dst))
print(f"Moved: {src.name} -> Documents/")

Output:

Moved: report.pdf -> Documents/

The dst_dir.mkdir(parents=True, exist_ok=True) call is a two-for-one: parents=True creates any missing parent directories, and exist_ok=True suppresses the error if the folder already exists. Always include both flags so the script is idempotent — safe to run multiple times on the same folder.

One edge case to handle: what if a file with the same name already exists in the destination? shutil.move() will silently overwrite it. To prevent data loss, check for a collision and rename the incoming file if needed:

# safe_move.py
import shutil
from pathlib import Path

def safe_move(src, dst_dir, dry_run=False):
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    counter = 1
    while dst.exists():
        dst = dst_dir / f"{src.stem}({counter}){src.suffix}"
        counter += 1
    if not dry_run:
        shutil.move(str(src), str(dst))
    return dst

src = Path("/tmp/messy_folder/report.pdf")
final = safe_move(src, Path("/tmp/messy_folder/Documents"))
print(f"File is now at: {final}")

Output (when a report.pdf already exists in Documents/):

File is now at: /tmp/messy_folder/Documents/report(1).pdf

This pattern — loop until the target path does not exist — is simple and collision-proof. It is the same approach used by macOS Finder and Windows Explorer when you copy a file into a folder that already contains a file with the same name.

Python file extension map dictionary mapping extensions to folder names
Your extension map is a lookup table. Garbage in, chaos out.

Adding a CLI with argparse

Hard-coding the target folder path inside the script is fine for personal use but gets annoying fast. Adding an argparse CLI lets you pass the folder as an argument — and add useful flags like --dry-run to preview what would move without actually moving anything.

# cli_demo.py
import argparse
from pathlib import Path

def parse_args():
    parser = argparse.ArgumentParser(
        description="Organize files in a folder by extension."
    )
    parser.add_argument(
        "folder",
        type=Path,
        help="Path to the folder you want to organize."
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview what would be moved without making any changes."
    )
    parser.add_argument(
        "--recursive",
        action="store_true",
        help="Also organize files in subdirectories."
    )
    return parser.parse_args()

args = parse_args()
print(f"Target folder : {args.folder}")
print(f"Dry run       : {args.dry_run}")
print(f"Recursive     : {args.recursive}")

Output (run as: python cli_demo.py ~/Downloads –dry-run):

Target folder : /home/user/Downloads
Dry run       : True
Recursive     : False

The --dry-run flag is invaluable when you are running the organizer on an unfamiliar folder. It lets you audit the plan before committing. We will wire it into the main loop in the Real-Life Example so that passing --dry-run prints every planned move but touches nothing on disk.

Real-Life Example: A Production-Ready File Organizer

Here is the complete script combining everything above — scanning, safe moving, a full extension map, CLI arguments, dry-run mode, and a summary report at the end. Save it as organize.py and run it from anywhere.

Python argparse CLI dry-run mode file organizer terminal output
python organize.py ~/Downloads –dry-run — before you commit to anything.
# organize.py
import argparse
import shutil
from pathlib import Path

EXT_MAP = {
    ".pdf": "Documents",  ".docx": "Documents", ".txt": "Documents",
    ".xls": "Documents",  ".xlsx": "Documents", ".pptx": "Documents",
    ".csv": "Documents",  ".doc": "Documents",
    ".jpg": "Images",  ".jpeg": "Images", ".png": "Images",
    ".gif": "Images",  ".webp": "Images", ".heic": "Images",
    ".svg": "Images",  ".raw": "Images",
    ".mp4": "Videos",  ".mov": "Videos",  ".avi": "Videos",
    ".mkv": "Videos",  ".webm": "Videos",
    ".mp3": "Audio",   ".wav": "Audio",   ".aac": "Audio",
    ".flac": "Audio",  ".m4a": "Audio",
    ".zip": "Archives", ".tar": "Archives", ".gz": "Archives",
    ".rar": "Archives", ".7z": "Archives",
    ".py": "Code",  ".js": "Code",   ".ts": "Code",
    ".html": "Code", ".css": "Code", ".json": "Code",
    ".yaml": "Code", ".yml": "Code", ".ipynb": "Code",
    ".exe": "Installers", ".dmg": "Installers", ".pkg": "Installers",
}

def safe_move(src, dst_dir, dry_run):
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    counter = 1
    while dst.exists():
        dst = dst_dir / f"{src.stem}({counter}){src.suffix}"
        counter += 1
    if not dry_run:
        shutil.move(str(src), str(dst))
    return dst

def organize(folder, recursive, dry_run):
    stats = {}
    iterator = folder.rglob("*") if recursive else folder.iterdir()
    for item in iterator:
        if not item.is_file() or not item.suffix:
            continue
        category = EXT_MAP.get(item.suffix.lower(), "Other")
        dst_dir = folder / category
        dst = safe_move(item, dst_dir, dry_run)
        label = "[DRY RUN] " if dry_run else ""
        print(f"  {label}{item.name}  ->  {category}/")
        stats[category] = stats.get(category, 0) + 1
    return stats

def main():
    parser = argparse.ArgumentParser(
        description="Organize files in a folder by extension."
    )
    parser.add_argument("folder", type=Path, help="Folder to organize.")
    parser.add_argument("--dry-run", action="store_true",
                        help="Preview moves without touching files.")
    parser.add_argument("--recursive", action="store_true",
                        help="Organize subdirectories too.")
    args = parser.parse_args()

    if not args.folder.is_dir():
        print(f"Error: {args.folder} is not a valid directory.")
        return

    print(f"Organizing: {args.folder}")
    if args.dry_run:
        print("(DRY RUN -- no files will be moved)\n")

    stats = organize(args.folder, args.recursive, args.dry_run)

    print("\n--- Summary ---")
    for category, count in sorted(stats.items()):
        print(f"  {category:15s} {count} file(s)")
    print(f"  {'TOTAL':15s} {sum(stats.values())} file(s)")

if __name__ == "__main__":
    main()

Output (dry-run on a cluttered Downloads folder):

Organizing: /home/user/Downloads
(DRY RUN -- no files will be moved)

  [DRY RUN] budget-2025.pdf  ->  Documents/
  [DRY RUN] photo-holiday.jpg  ->  Images/
  [DRY RUN] project.zip  ->  Archives/
  [DRY RUN] meeting-notes.docx  ->  Documents/
  [DRY RUN] setup.exe  ->  Installers/
  [DRY RUN] demo-clip.mp4  ->  Videos/

--- Summary ---
  Archives        1 file(s)
  Documents       2 file(s)
  Images          1 file(s)
  Installers      1 file(s)
  Videos          1 file(s)
  TOTAL           6 file(s)

Run it for real by dropping --dry-run: python organize.py ~/Downloads. The script is structured so you can extend it easily — add more extensions to EXT_MAP, add a --log flag that writes moves to a text file, or drop in a date-based sorting layer for photos.

Python file organizer summary report organized files by category
Summary report: because ‘it probably worked’ is not a monitoring strategy.

Frequently Asked Questions

What happens if two files have the same name?

The safe_move() function handles this with a counter loop. If report.pdf already exists in Documents/, the incoming file becomes report(1).pdf. If that also exists, it becomes report(2).pdf, and so on. This protects you from silent data overwrites that shutil.move() would otherwise allow by default.

Can I undo the organization after running it?

Not automatically — shutil.move() does not maintain an undo log. For a safety net, always run with --dry-run first to verify the plan. For a real undo capability, modify the script to write every move to a JSON log file ({"src": "...", "dst": "..."}), then write a companion script that reverses each entry. Alternatively, test on a copy of the folder before touching the original.

How do I organize files by date instead of extension?

Swap the extension lookup for a date lookup using the file’s modification time. item.stat().st_mtime returns a Unix timestamp; pass it to datetime.fromtimestamp() and use strftime("%Y/%m") to get a 2026/08 folder path. For photos with EXIF data, the Pillow library can read the DateTimeOriginal tag for more accurate dates than the filesystem timestamp.

How do I run this automatically every day?

On macOS and Linux, add a cron job: open a terminal, run crontab -e, and add 0 8 * * * python3 /path/to/organize.py ~/Downloads to run it at 8 AM daily. On Windows, use Task Scheduler and point it at a batch file that calls your Python script. Both approaches run the script silently in the background without any manual action required.

Does this script handle hidden files (dotfiles)?

Path.iterdir() returns hidden files (files starting with . on Unix, like .DS_Store or .gitignore). The script currently skips files with no extension (the if not item.suffix guard), which silently ignores many dotfiles. If you want to explicitly skip all hidden files, add if item.name.startswith("."): continue right after the is_file() check.

What if I want different category names than the defaults?

Edit the EXT_MAP dictionary directly. The values are plain strings — change "Documents" to "Docs", or "Images" to "Photos", and the script will create folders with those names. You can also load the map from a JSON config file at runtime if you want to share settings across multiple machines without editing the script itself.

Conclusion

You have built a complete, production-ready file organizer in Python using only the standard library. The key tools are pathlib.Path.iterdir() for scanning, item.suffix.lower() for extension detection, shutil.move() for relocating files, and argparse for a proper CLI with --dry-run and --recursive flags. The safe_move() helper prevents silent data overwrites by appending a counter to duplicate filenames.

From here, consider extending the script with date-based sorting for photos (using datetime.fromtimestamp(item.stat().st_mtime)), a JSON move log for undo support, or a --watch mode using the watchdog library to organize files as they arrive. The structure is already in place — each extension is another entry in the map, and each new mode is a flag away.

Official documentation: pathlib — Object-oriented filesystem paths and shutil — High-level file operations.