Fundamentals

Rename and Sort Files with Python and AI

Turn scan_0034.pdf into 2026-03-11-acme-invoice.pdf. Read a preview, get a strict JSON verdict, sanitise the filename, dry-run every move, then undo it.

By the end of this guide you will have a Python script that reads a folder of meaningless filenames — scan_0034.pdf, IMG_2291.png, document(3).pdf — looks inside each one, and proposes a name like 2026-03-11-acme-invoice.pdf filed under a category folder. It prints every planned move before it touches anything, and it writes an undo log so a bad batch is one command away from being reversed. Setup takes about twenty minutes; the script itself runs in seconds per file.

The reason this task is worth automating is that the information you need is already inside the file, just not in its name. A scanner names by counter, a browser names by URL fragment, a phone names by timestamp. A model that reads the first page can tell you it is an invoice from Acme dated 11 March, which is exactly the three things you would have typed by hand.

This guide belongs to Automating Repetitive Tasks with Python and AI, the section that covers turning a recurring chore into a script you can schedule. If you have already worked through Python Script to Automate Email Sorting, the classify-then-route pattern here will feel familiar — but the stakes are higher, because a wrong move on a filesystem is harder to spot than a misfiled email.

Prerequisites

You need Python 3.10 or newer and an activated virtual environment (an isolated folder of packages, so this project cannot break another one). If that phrase is new, Create a Python Virtual Environment for AI walks through it. Beyond the parent guide's setup you need one extra package, pypdf, to pull text out of PDFs:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "pypdf>=5.0" "python-dotenv>=1.0"
pip freeze > requirements.txt

Put your API key in a .env file beside the script:

OPENAI_API_KEY=sk-your-key-here

Add that file to .gitignore before you write another line, so the key never reaches a public repository:

echo ".env" >> .gitignore

Finally, make a copy of the folder you plan to sort and point the script at the copy for your first few runs. No amount of validation beats testing on data you can afford to lose.

Step 1 — Walk the folder and read a preview

pathlib is the standard-library module for working with paths. Its rglob("*") method yields every entry beneath a folder, including files in subfolders, so one loop covers a messy download pile. You filter out directories and dot-files, then read a short preview of each file rather than the whole thing — a page or two of text is plenty to identify a document, and sending less text keeps each request fast and cheap.

from pathlib import Path

from pypdf import PdfReader

PREVIEW_CHARS = 1500
TEXT_SUFFIXES = {".txt", ".md", ".csv", ".log"}


def read_preview(path: Path) -> str:
    """Return a short, whitespace-normalised text sample from one file."""
    suffix = path.suffix.lower()
    if suffix == ".pdf":
        reader = PdfReader(path)
        raw = "\n".join((page.extract_text() or "") for page in reader.pages[:2])
    elif suffix in TEXT_SUFFIXES:
        raw = path.read_text(encoding="utf-8", errors="ignore")
    else:
        raw = ""  # images and unknown types: fall back to the filename alone
    return " ".join(raw.split())[:PREVIEW_CHARS]


def candidates(source: Path) -> list[Path]:
    """Every real file under source, in a stable order."""
    return sorted(
        p for p in source.rglob("*")
        if p.is_file() and not p.name.startswith(".")
    )


if __name__ == "__main__":
    folder = Path.home() / "Downloads" / "to-sort"
    for path in candidates(folder):
        print(path.name, "->", read_preview(path)[:80] or "(no text found)")

Two details matter. errors="ignore" stops one file with an odd byte from crashing the whole run. And " ".join(raw.split()) collapses the runs of newlines and spaces that PDF extraction produces, so your 1,500-character budget is spent on words instead of whitespace.

The full pipeline you are about to build has six stages, and every stage after the model exists to keep the model's answer out of direct contact with your disk.

The six stages of the AI file-renaming pipeline A data-flow diagram running left to right along the top row from walking the folder to reading a preview to asking the model, then right to left along the bottom row through validating the JSON, printing a dry run, and applying the moves with an undo log. Walk the folder pathlib rglob Read a preview first 1,500 chars Ask the model JSON verdict only Validate the JSON sanitise the name Print a dry run nothing is moved Apply the moves write undo.jsonl
The model appears once, in the top-right box; everything on the bottom row is your own code deciding whether to trust what came back.

Step 2 — Ask the model for a structured verdict

A free-text answer is useless here. You want four fields every time: a category from a fixed list, a slug (a short hyphenated label), a date, and a confidence score you can threshold on. The OpenAI API supports a strict JSON schema, which means the response is guaranteed to be valid JSON with exactly those keys — no code fences, no apology paragraph, no missing field.

Set temperature to 0 so the same file always produces the same name; temperature controls randomness, and randomness is the enemy of a repeatable filing system. What Is Temperature in an LLM API? explains the trade-off in more depth.

import json
import os

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

CATEGORIES = ["invoice", "receipt", "contract", "statement", "letter", "other"]

VERDICT_SCHEMA = {
    "name": "file_verdict",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "category": {"type": "string", "enum": CATEGORIES},
            "slug": {"type": "string"},
            "date": {"type": "string"},
            "confidence": {"type": "number"},
        },
        "required": ["category", "slug", "date", "confidence"],
        "additionalProperties": False,
    },
}

SYSTEM = (
    "You name scanned documents. Reply with the category, a two-to-four word "
    "lowercase hyphenated slug naming the sender and document type, the "
    "document date as YYYY-MM-DD (use 0000-00-00 if none is visible), and a "
    "confidence between 0 and 1. Never include a file extension or a slash."
)


def classify(filename: str, preview: str) -> dict:
    """Ask the model what this file is. Returns a dict, never a path."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        response_format={"type": "json_schema", "json_schema": VERDICT_SCHEMA},
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": f"Filename: {filename}\n\nText:\n{preview}"},
        ],
    )
    return json.loads(response.choices[0].message.content)

For scan_0034.pdf containing an Acme invoice, you get back something like {"category": "invoice", "slug": "acme-invoice", "date": "2026-03-11", "confidence": 0.93}. Notice what the function returns: a plain dictionary. It does not return a path, and nothing in this file writes to disk. Keeping the model's job to "describe" rather than "decide where" is the single most important design choice on this page.

If you want the model to follow instructions like this more reliably, Write System Prompts that Control Output Format covers the wording patterns that hold up across a batch. And if you are pulling structured fields out of documents at a larger scale, Extract Invoice Data from PDFs with Python and AI goes further than naming.

Step 3 — Validate the JSON and sanitise the filename

Here is the rule the rest of this guide is built on: a model's output is untrusted text, and untrusted text must never become a filesystem path without being rewritten first. A slug of ../../.ssh/config is a plausible thing for a confused model to emit if a document happens to contain that string, and joining it onto a folder path would write outside your destination entirely. The defence is not to check for bad characters — it is to keep only the good ones.

Everything below either survives a strict allowlist or gets replaced. The category must be one of your six known values; the date must match the exact YYYY-MM-DD shape; the slug is rebuilt character by character from a-z, 0-9 and hyphen, which quietly destroys slashes, dots, backslashes, colons and null bytes as a side effect.

import re
import unicodedata
from datetime import date
from pathlib import Path

MAX_STEM = 80
SLUG_RE = re.compile(r"[^a-z0-9-]+")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
VALID_CATEGORIES = set(CATEGORIES)


def slugify(raw: str) -> str:
    """Rewrite arbitrary text as a safe lowercase filename stem."""
    folded = unicodedata.normalize("NFKD", raw).encode("ascii", "ignore").decode()
    cleaned = SLUG_RE.sub("-", folded.lower())
    cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
    return cleaned[:MAX_STEM] or "untitled"


def target_path(dest_root: Path, verdict: dict, original: Path) -> Path:
    """Build the new path from a verdict. Every field is re-derived here."""
    category = verdict.get("category")
    if category not in VALID_CATEGORIES:
        category = "other"

    day = str(verdict.get("date", ""))
    if not DATE_RE.match(day) or day.startswith("0000"):
        day = date.fromtimestamp(original.stat().st_mtime).isoformat()

    stem = f"{day}-{slugify(str(verdict.get('slug', '')))}"
    suffix = original.suffix.lower()          # extension comes from the file
    folder = dest_root / category
    candidate = folder / f"{stem}{suffix}"

    counter = 2
    while candidate.exists():                 # never overwrite anything
        candidate = folder / f"{stem}-{counter}{suffix}"
        counter += 1

    resolved = candidate.resolve()
    if not resolved.is_relative_to(dest_root.resolve()):
        raise ValueError(f"refusing to write outside {dest_root}: {resolved}")
    return candidate

The final is_relative_to check is a belt-and-braces guard: even if a future edit weakens slugify, a path that escapes the destination folder raises instead of running. The extension is taken from the original file, never from the model, because a .pdf renamed to .exe is a genuinely dangerous outcome.

Each incoming verdict runs the same gauntlet, and every branch has a defined answer — there is no path where a surprising value reaches Path.rename.

The validation gauntlet a model verdict must pass A decision tree with three checks stacked vertically — did the reply parse as valid JSON, does the slug contain only safe characters, and is the target name already taken — each with a branch to the right showing the fallback action, ending in a queued rename. Parsed as JSON? all four keys present Keep the old name log it and move on Slug chars legal? a-z, 0-9 and hyphen Rebuild the slug drop everything else Name already used? candidate.exists() Append -2, then -3 until one is free Queue the rename still nothing moved no no yes yes yes no
Every "no" branch has a defined fallback, so a malformed verdict downgrades the result instead of stopping the batch or escaping the destination folder.

Step 4 — Print a dry run before anything moves

A dry run is a pass that computes the entire plan and prints it without changing a single byte. It costs you the API calls but nothing else, and it is the moment you catch a model that has decided every file is a receipt. Build the plan as data first, print it, and only then decide whether to act on it.

import argparse
from pathlib import Path

CONFIDENCE_FLOOR = 0.55


def build_plan(source: Path, dest_root: Path) -> list[tuple[Path, Path]]:
    """Return a list of (old_path, new_path) pairs. Touches nothing."""
    planned: list[tuple[Path, Path]] = []
    for path in candidates(source):
        try:
            verdict = classify(path.name, read_preview(path))
        except Exception as exc:                       # one bad file must not stop the run
            print(f"SKIP  {path.name}: {type(exc).__name__}: {exc}")
            continue
        if float(verdict.get("confidence", 0)) < CONFIDENCE_FLOOR:
            print(f"SKIP  {path.name}: low confidence {verdict.get('confidence')}")
            continue
        planned.append((path, target_path(dest_root, verdict, path)))
    return planned


def show(plan: list[tuple[Path, Path]]) -> None:
    for old, new in plan:
        print(f"MOVE  {old.name}\n  ->  {new.relative_to(new.parents[1])}")
    print(f"\n{len(plan)} file(s) planned. Nothing has been changed.")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Rename and sort files with AI.")
    parser.add_argument("source", type=Path)
    parser.add_argument("dest", type=Path)
    parser.add_argument("--go", action="store_true", help="actually move the files")
    args = parser.parse_args()

    plan = build_plan(args.source, args.dest)
    show(plan)

Run it with python sortfiles.py ~/Downloads/to-sort ~/Documents/filed and you get a readable list: MOVE scan_0034.pdf -> invoice/2026-03-11-acme-invoice.pdf. The --go flag exists but does nothing yet, which is deliberate — the safe mode is the default, and the destructive mode needs a conscious extra word on the command line.

Two guards are worth calling out. The broad except Exception around the API call means a corrupt PDF or a network hiccup skips one file instead of ending the batch; if a traceback still reaches you, Read a Python Traceback in Five Minutes will get you to the cause quickly. The confidence floor means the model can say "I am not sure" and have that respected, leaving genuinely ambiguous files where they are for you to handle by hand.

Step 5 — Execute the moves and write an undo log

Now the --go branch earns its place. Each move is written to an append-only log file as one JSON object per line — a format called JSON Lines, chosen because a crash halfway through leaves every completed line intact and readable. Write the log entry after the rename succeeds, so the log only ever describes moves that really happened.

import json
from datetime import datetime
from pathlib import Path

LOG_PATH = Path("undo.jsonl")      # keep this OUTSIDE the folder you are sorting


def apply_plan(plan: list[tuple[Path, Path]], log_path: Path = LOG_PATH) -> int:
    moved = 0
    with log_path.open("a", encoding="utf-8") as log:
        for old, new in plan:
            new.parent.mkdir(parents=True, exist_ok=True)
            if new.exists():                       # someone beat us to it
                print(f"SKIP  {old.name}: {new.name} appeared during the run")
                continue
            old.rename(new)                        # same drive; see troubleshooting
            log.write(json.dumps({
                "from": str(old.resolve()),
                "to": str(new.resolve()),
                "at": datetime.now().isoformat(timespec="seconds"),
            }) + "\n")
            log.flush()
            moved += 1
    return moved


def undo(log_path: Path = LOG_PATH) -> int:
    """Reverse every logged move, newest first."""
    lines = log_path.read_text(encoding="utf-8").splitlines()
    restored = 0
    for line in reversed(lines):
        row = json.loads(line)
        new, old = Path(row["to"]), Path(row["from"])
        if new.exists() and not old.exists():
            old.parent.mkdir(parents=True, exist_ok=True)
            new.rename(old)
            restored += 1
    return restored

Wire it into the entry point by replacing the last line of Step 4 with a branch on args.go: call apply_plan(plan) when the flag is present, show(plan) otherwise. Add a third flag, --undo, that calls undo() and prints the count. Reversing newest-first matters because a later move might have used a name that an earlier move freed up.

The three modes differ in exactly three ways, and knowing which is which before you press enter is most of the safety story.

Dry run compared with apply across four behaviours A comparison matrix with four rows — filesystem changes, console output, undo log, and rerun safety — showing what the default dry run does versus what the apply mode triggered by the go flag does. Behaviour Dry run Apply --go Filesystem what changes Nothing moves read-only pass Files renamed one move at a time Console what you see Full move list old name to new Line per file plus any skips Undo log undo.jsonl Not written nothing to reverse One row a move append-only file Rerun safety running it twice Run it freely no side effects Run once then read the log
The dry run is free to repeat as often as you like; the apply pass is the only mode that changes anything, and it is the only one that leaves a trail you can walk backwards.

Sanitising rules at a glance

These six rules are the whole contract between the model's suggestion and your disk. Each one turns a class of bad input into a boring, predictable output.

RuleWhat it preventsImplementation
Fold to ASCII, lowercaseUnicode look-alikes and case-only clashesunicodedata.normalize("NFKD", raw)
Allowlist a-z0-9-Slashes, dots and .. escaping the folderre.sub(r"[^a-z0-9-]+", "-", s)
Collapse and trim hyphensNames like --acme--invoice--re.sub(r"-{2,}", "-", s).strip("-")
Cap the stem at 80 charsPath-length limits on Windowscleaned[:MAX_STEM]
Extension from the fileA PDF renamed to an executableoriginal.suffix.lower()
Suffix on collisionSilently overwriting an existing filewhile candidate.exists()

Troubleshooting

OSError: [Errno 18] Invalid cross-device linkPath.rename cannot move a file between two drives or partitions, which is common when the source is an external disk. Swap the rename for shutil.move(old, new), which copies then deletes when it has to.

FileNotFoundError: [Errno 2] No such file or directory — the category subfolder does not exist yet. Make sure new.parent.mkdir(parents=True, exist_ok=True) runs before the rename, not after it.

PermissionError: [Errno 13] Permission denied — the file is open in another application, most often a PDF viewer on Windows. Close the viewer and rerun; because the undo log records only completed moves, a partial batch is safe to resume.

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the reply was not JSON, usually because the response_format argument was dropped or the model was swapped for one that does not support schemas. Fix JSONDecodeError with AI API Responses in Python covers the fallbacks.

openai.RateLimitError: Error code: 429 — you are sending files faster than your account allows. Add a short time.sleep between calls or follow the retry pattern in Fix the 429 Rate-Limit Error in Python.

When to use this vs. alternatives

  • Use plain rules, not AI, when the filename already tells you enough. If every file from your accounting tool is called INV-2026-0031.pdf, a regular expression is faster, free, and never wrong. Reach for a model only when the answer lives in the content rather than the name.
  • Use a desktop rules app when you want live, always-on filing and your criteria are simple. Tools that watch a folder and act on extension or sender handle that with no code. This script wins when the decision needs reading comprehension and when you want the plan reviewable before it runs.
  • Use a document-processing pipeline when you need the data, not just the name. If you want the invoice total in a spreadsheet rather than a tidy filename, Classify Incoming Documents with Python and AI is the better starting point.

Once the dry run looks right on a copy of your folder, point it at the real one, keep the undo log somewhere you will find it, and consider running it on a timer — Schedule Python AI Jobs with GitHub Actions shows one way to do that without leaving a laptop on. The same shape of script — collect, classify, validate, act, log — is what powers Automate a Weekly Report with Python and AI too, so the effort you put in here transfers. Back to Automating Repetitive Tasks with Python and AI.

Frequently asked questions

Can Python rename files based on what is inside them?

Yes. Python reads a short preview of each file, sends that text to an AI model, and gets back a suggested category and name. Your code then builds the new filename itself from those values. The model never touches the disk, so a strange answer becomes a rejected suggestion rather than a bad file.

Is it safe to let an AI rename my files?

It is safe when the model only proposes and your code decides. Strip every character that is not a letter, digit or hyphen from its answer, cap the length, and refuse anything that would land outside your destination folder. Run in preview mode first and keep an undo log of every move.

What happens if two files get the same new name?

Your script checks whether the target path already exists and appends a numeric suffix until it finds a free one, so the second file becomes name-2.pdf and the third name-3.pdf. Never overwrite silently: a collision that clobbers a file is unrecoverable without a backup.

How do I undo an AI file rename that went wrong?

Write one JSON line per move as you go, recording the old path and the new path. To reverse the batch, read that log, walk it backwards, and rename each file from its new path back to its old one. Keep the log outside the folder you are sorting.

Which model should I use for sorting files?

A small, cheap model such as gpt-4o-mini handles this well, because the job is short classification rather than deep reasoning. Set temperature to 0 so identical files get identical names, and request a strict JSON schema so every reply has the same shape.