Skip to main content
Learning Center
Workflow Automation

Organize Files

Renaming Files Without Losing One

Turning a folder survey into a plan of renames, finding the two files that want the same new name before anything moves, and refusing the whole plan rather than applying half of it.

Lesson 5 of 18 in the recommended order · About 30 min (estimate)

On this page
Workflow automation glossary — terms and common confusions
Dry run
A run that reports every change it would make and makes none of them.

A dry run that still writes a log file, sends a message, or creates a folder is not a dry run.

Idempotent
Running it again leaves the same result as running it once.

"It did not crash the second time" is not the same property; check what the second run changed.

Run record
The stored facts about one execution: when it started, what it read, what it changed, how it ended.

A run record is not the log. The log is prose for a person; the record is data the next run reads.

Transient failure
A failure that a later identical attempt could succeed at, such as a timeout.

A rejected record and a dropped connection are not the same failure, and retrying the first one forever is a bug.

Backoff
Waiting longer between successive retries instead of retrying immediately.

Backoff without a maximum attempt count is an unbounded loop with a politeness delay.

Request budget
A hard cap on how many requests one run may make, checked before each request.

A page limit is not a budget if a retry can make extra requests the limit never counts.

Quarantine
Setting aside a record a run could not process, with the reason, so the run continues.

Quarantine is not "skip". A skipped record leaves no trace; a quarantined one is countable and reviewable.

Reconciliation
Showing that the counts in a report add up: read equals processed plus quarantined plus rejected.

A report whose totals cannot be reconciled is a summary of what the code believed, not of what happened.

Trigger
The event or time that causes a run to start.

Scheduling a trigger is a decision made on a machine, not something a browser lesson can install for you.

Heartbeat
A signal a healthy run emits, whose absence is itself the alert.

Alerting only on errors cannot detect the job that stopped running at all, which is the most common outage.

Staleness
How old the most recent successful run is, compared with how old it is allowed to be.

A green last run is not freshness. Ask when it ran, not whether it passed.

Atomic replace
Writing output to a temporary name and renaming it into place in one step.

A reader never sees a half-written file; an interrupted run leaves the previous output intact.

Transport
The injected callable that actually performs a request, separate from the client that interprets it.

A client that builds its own connection cannot be tested without a network, which is why the seam exists.

In-memory filesystem
The filesystem this course’s browser exercises operate on, which lives only inside the tab.

It behaves like a filesystem and is not your disk: nothing an exercise writes exists after the run ends.

Outcome

By the end of this lesson you can take the survey from the previous lesson, compute the new name for every file before touching any of them, detect the case where two files want the same new name, and refuse the entire operation rather than performing the half of it that happens to be safe.

Why it matters

Renaming is the most dangerous thing in this module, and it does not look dangerous.

Here is the whole problem in two files. A folder contains Invoice 2026-01.pdf and Invoice-2026-01.pdf — a scan and a re-scan, saved under different names. These names coexist on Windows as well as POSIX filesystems. A tidying script lowercases names and replaces spaces with hyphens. Both files want to become invoice-2026-01.pdf.

If a script iterates those sources and applies both moves with os.replace without a preflight collision check, both calls can succeed while the folder ends with one file. Which file survives under the new name depends on iteration order. A summary saying two moves completed could be true while silently hiding the overwritten file.

That is not a rare edge case. Spaces versus hyphens and copies named report (1).csv are common; case-only pairs also occur on case-sensitive filesystems. Any normalising rule maps some distinct names together.

The fix is not a cleverer naming rule. It is to compute every target first, look at the whole set, and treat "two sources, one target" as a reason to stop — because at that point a person has to decide which file is the real one, and that is exactly the handoff the first module taught you to find.

Concept

Plan every target before moving anything. The survey produces a list of (source, target) pairs. That list is data, it can be printed and reviewed, and — the point of this lesson — it can be checked as a whole before any of it happens.

The collision check is one line over the finished plan. Count how many sources produce each target; any target with more than one source is a collision. Doing this per file as you go cannot work: the second file only conflicts with the first once the first has been decided, and by then it has often already moved.

There are three target situations worth distinguishing:

  1. Two sources, one target. Two files in the plan normalise to the same name.
  2. A target that already exists outside the plan. A selected subset may aim at an excluded file, or a file may aim at an existing directory. This example plans every ordinary file, so it does not exercise the excluded-file case.
  3. A target that is already a source in the plan. With this idempotent naming rule, a file already named correctly maps to itself; that is a no-op, not a collision. A different rule could make one source want another source's old name, which needs a deliberate ordering or temporary-name plan.

How to move a file, and what each one does when the target exists:

path.rename(target)      Windows: raises FileExistsError. Linux/macOS: replaces silently.
os.replace(src, target)  Requests replacement on every platform; one move is not a batch transaction.
shutil.move(src, target) An existing file may be overwritten; moves *into* a target directory.

path.rename behaving differently on different platforms is a real trap: code tested on Windows raises where code tested on Linux quietly overwrote. os.replace consistently requests replacement of an existing target; one successful rename is atomic where the platform guarantees it, but a loop of renames is not a transaction. It overwrites. Neither call checks the whole plan for you; the planning check is yours.

shutil.move also moves a source inside an existing destination directory. For an existing file, whether it is overwritten depends on os.rename behavior and fallback; do not use it as a no-overwrite guarantee.

Refuse the whole plan, not the offending file. A plan with one collision in it describes a folder somebody is confused about. Applying the other nineteen renames leaves that folder in a state that is neither what it was nor what was intended, and the person who has to sort it out now has to work out which nineteen already happened. Stopping costs one run; a half-applied plan costs an afternoon.

Read the code

A plan, a collision check, and an apply step that respects it:

import os
import pathlib
import tempfile

root = pathlib.Path(tempfile.mkdtemp()) / "scans"
root.mkdir(parents=True)
for name in ["Receipt March.PDF", "receipt-March.PDF", "Ledger 2026.csv"]:
    (root / name).write_text(name, encoding="utf-8")


def filed_name(path):
    """Lowercase, spaces to hyphens, suffix lowercased."""
    return path.stem.lower().replace(" ", "-") + path.suffix.lower()


def plan_renames(folder):
    moves = sorted((entry.name, filed_name(entry)) for entry in folder.iterdir() if entry.is_file())
    targets = {}
    for source, target in moves:
        targets.setdefault(target, []).append(source)
    collisions = sorted(target for target, sources in targets.items() if len(sources) > 1)
    return moves, collisions


moves, collisions = plan_renames(root)
for source, target in moves:
    print(f"would rename {source} -> {target}")
print("collisions:", len(collisions))
for target in collisions:
    print("  contested target:", target)
print("applied:", 0)  # this is a dry run; no rename call occurs
print("still present:", len(sorted(root.iterdir())))

setdefault(target, []).append(source) builds "which sources want this target" in one pass. Keeping the sources, rather than just counting, is what lets the message name the two files a person has to choose between.

The applied: 0 line reports what this dry run actually does: it never calls a rename, whether or not the plan has a collision. The exercise adds the real move and refuses its detected collision before that move loop.

still present reads the folder again at the end. Its count supports the dry-run claim in this isolated sample, but equal counts alone would not prove that names or bytes stayed unchanged. Compare a before/after inventory when checking real files.

Predict the output

Predict every line.

Check your prediction
would rename Ledger 2026.csv -> ledger-2026.csv
would rename Receipt March.PDF -> receipt-march.pdf
would rename receipt-March.PDF -> receipt-march.pdf
collisions: 1
  contested target: receipt-march.pdf
applied: 0
still present: 3

moves is sorted by source name, so the capitalised names come first. Two of the three sources produce receipt-march.pdf, which is the collision. Nothing was applied, and all three files are still there.

The two contested files differ in their separator and case. Both can exist in a typical Windows folder, yet the naming rule maps them to the same target. The convergence is obvious in the plan.

Two receipt names converge before any file moves

Two sources want the same target

  • Source 1Receipt March.PDF
  • Source 2receipt-March.PDF

Both normalize to this one target:

receipt-march.pdf

Separate source, also blocked by this collision

SourceLedger 2026.csv normalizes to Targetledger-2026.csv

Preflight decision

Contested targets
1
Moves applied
0
Original entries still present
3

The two-source collision refuses this demonstrated plan before the apply loop. Even the separate Ledger rename stays pending.

Folder identity

Before
Ledger 2026.csv; Receipt March.PDF; receipt-March.PDF
After
Ledger 2026.csv; Receipt March.PDF; receipt-March.PDF

The files also retain their distinct original bytes in this dry run. This is not a promise of rollback after a later I/O failure.

Every source and target is named in selectable text. Two sources seek receipt-march.pdf; the third has its own target but is blocked with the whole plan. This example detects planned many-to-one targets only. It does not preflight an occupied destination outside the plan, prevent concurrent writes, or make a multi-file move atomic.

Modify the code

Delete the collision check entirely and make the loop rename as it goes:

for entry in root.iterdir():
    if entry.is_file():
        os.replace(entry, entry.parent / filed_name(entry))
What changes, and why

still present becomes 2. One of the two receipts is gone, and which one depends on the order iterdir() returned them in — so the same code, over the same folder, can destroy either file on different machines.

Nothing raised. os.replace overwrites by design and reports success, which is correct behaviour for a function whose job is to replace. The program asked it to overwrite a file and it did.

Three things are worth separating here, because they are usually blamed on each other:

  • os.replace is not the bug. It did what it is documented to do. Swapping it for path.rename would raise on Windows and silently overwrite on Linux, which is worse: a defect that only appears on some machines.
  • The naming rule is not the bug either. Lowercasing and hyphenating is a reasonable rule; every reasonable rule maps some distinct names together.
  • Deciding one file at a time is the bug. The information needed to detect the collision — that another file wants this name — exists only in the complete plan, and the loop never builds one.

Debug the bug

An assistant was asked to "tidy up the filenames in this folder, and don't overwrite anything". It produced this.

def tidy(folder):
    renamed = 0
    for entry in sorted(folder.iterdir()):
        target = folder / filed_name(entry)
        if target.exists():
            print(f"skipping {entry.name}, target exists")
            continue
        entry.rename(target)
        renamed += 1
    print(f"renamed {renamed} files")
What's actually wrong

In this unchanged practice folder, its exists() check avoids an overwrite, but the code is still wrong in three ways. A concurrent writer can invalidate that check.

  1. It skips one contested file, silently and arbitrarily. Over the practice folder, Receipt March.PDF sorts first and is renamed to receipt-march.pdf. Then receipt-March.PDF finds its target occupied and is skipped. The one filed under the tidy name is decided by sort order, and the message scrolls past in a log nobody reads. A person still has to choose between those two files, and this has silently chosen for them.

  2. target.exists() is a check about the past, not the plan. It only sees files that exist at the moment it is asked. It cannot see that a later file in the same loop is going to want this name, which is the case that matters.

  3. It renames folders. entry is never checked with is_file(), so a subfolder called Old Scans is renamed to old-scans — and filed_name computes path.stem and path.suffix on a directory name, so Old Scans.backup becomes old-scans.backup by luck rather than intent.

A separate race remains: another writer can create the target after target.exists() returns false and before entry.rename(target) runs. On POSIX that rename may overwrite the new target; on Windows it raises. This toy folder has no concurrent writer, but the check does not make a real folder safe.

A version that refuses the demonstrated two-sources-one-target collision before moving:

def tidy(folder, apply_changes=False):
    moves, collisions = plan_renames(folder)
    if collisions:
        for target in collisions:
            print(f"refusing: more than one file wants the name {target}")
        return {"status": "refused", "collisions": collisions, "renamed": 0}
    if not apply_changes:
        return {"status": "planned", "collisions": [], "renamed": 0}
    for source, target in moves:
        os.replace(folder / source, folder / target)
    return {"status": "applied", "collisions": [], "renamed": len(moves)}

Every target is computed before anything moves, and a detected two-source collision refuses the whole plan before the apply loop. The dry run is the default, and plan_renames filters to is_file() so ordinary folders are left alone. This example does not preflight occupied destinations outside its plan, protect against concurrent writers, or roll back a later I/O failure.

Try it yourself

The starter builds two practice folders in the in-memory filesystem: messy, whose names collide once normalised, and clean, whose names do not. Write plan_renames and apply_renames. The plan must find the collision; the apply step must change nothing when there is one.

Loading this exercise…

Practical challenge (optional)

Optional, and the transfer task for this lesson: handle the second kind of collision.

The plan above catches two sources aiming at one target. Give the folder an existing directory named ledger-2026.csv alongside the file Ledger 2026.csv. The planner filters out the directory, so it misses this occupied target. Extend plan_renames to report that conflict before applying anything. Then explain what happens when a target is already a file in the plan under this idempotent naming rule.

What a good answer looks like

Check each target that differs from its source name: if the target exists and is not a source that will move out of the way, refuse the plan. This catches the directory called ledger-2026.csv. A file whose name is already correct maps to itself and needs no move; do not call it a collision.

With this naming rule, an existing normalized file is a self-target and does not move elsewhere. A different rule could create a chain or swap among source names; unique temporary names can resolve that ordering problem, but the two phases are still not a transaction and need failure recovery.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. Why can a collision not be detected reliably one file at a time?
  2. What does os.replace do when the target exists, and how does path.rename differ?
  3. Why refuse the whole plan rather than skipping the colliding file?
  4. Two files differ only by a space versus a hyphen. Why can they collide under this naming rule on both Windows and POSIX filesystems?
Answers
  1. The information is in the complete set of targets. A per-file check only sees files that exist at that moment, and the second file to want a name conflicts with the first only after the first has already been decided — often after it has already moved.
  2. os.replace requests replacement of an existing target on every platform. A successful single rename is atomic where the platform guarantees it; a loop is not. path.rename raises FileExistsError for an existing target on Windows and overwrites on POSIX, so the same code behaves differently depending on where it runs.
  3. A half-applied plan leaves a folder in a state that is neither the old one nor the intended one, and nobody can tell which renames happened. Refusing costs one run; the alternative costs an afternoon of reconstruction.
  4. This rule replaces spaces with hyphens, so Receipt March.PDF and receipt-March.PDF both target receipt-march.pdf. Those names coexist on Windows and POSIX; case-only pairs can additionally collide on case-sensitive filesystems.

Sign in to track your progress on this exercise.

Summary and next step

Compute every target before moving, group the plan by target to find names more than one source wants, and refuse a detected collision before any move. os.replace requests replacement consistently; protect other occupied targets and plan for failures before using it on real files. A multi-file loop is not atomic. Next: the other half of the duplicate problem, where two files have different names and identical contents, and the question is which copy to keep.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.