Python Part I · Module 15 · File Handling
Creating, Renaming and Deleting Files
Three operations that change the filesystem rather than reading it, which makes them categorically different from everything else in this module: a wrong read produces a wrong answer you can correct, and a wrong delete produces nothing at all. The mechanics are three method calls. The reason this chapter exists is that these calls will eventually run inside a script nobody is watching, at an hour nobody is awake, against paths assembled from strings that came from somewhere else — and one of the three does something quietly destructive that almost nobody expects.
Foundation15 min readPythonPart IFundamentalsFilesAutomation
Part I · Module 15 · Chapter 15.9 · Creating, Renaming and Deleting Files
1. Changing the Filesystem
Everything until now either read a file or wrote one you named. This chapter creates, moves and removes — operations with a property reads do not have: if you get them wrong, there may be nothing left to inspect.
The canonical sequence:
from pathlib import Path
p = Path("report.txt")
p.write_text("done") # create
p.rename("report.old") # move
p.unlink(missing_ok=True) # removeFour lines, three irreversible operations, and one of them behaves differently from how it reads. Taken in order.
2. Creating
A file comes into existence the moment something opens it for writing:
from pathlib import Path
p = Path("report.txt")
print("before:", p.exists())
p.write_text("done\n")
print("after :", p.exists(), p.stat().st_size, "bytes")
print("read :", repr(p.read_text()))before: False
after : True 5 bytes
read : 'done\n'write_text is the short form of the with block from Chapter 15.6 — it opens
in "w", writes, and closes. read_text is the short form of Chapter 15.2's
read.
Both handle the whole file at once, so Chapter 15.7's rule applies without modification: use them for small files you control — a status file, a seed, a short report — and never for a simulation log.
They also inherit "w" semantics, including the one that matters:
from pathlib import Path
p = Path("report.txt")
p.write_text("last night's results\n")
p.write_text("tonight\n")
print(repr(p.read_text()))'tonight\n'write_text replaces. There is no append form; for that, use
open(path, "a") as Chapter 15.6 did.
3. Creating Only If Absent
When overwriting would be a bug, say so — "x" mode from Chapter 15.6, which
has no Path shorthand:
from pathlib import Path
Path("report.txt").write_text("existing\n")
for attempt in ["report.txt", "fresh.txt"]:
try:
with open(attempt, "x") as f:
f.write("created by this run\n")
print(f"{attempt:<12} created")
except FileExistsError:
print(f"{attempt:<12} already exists -- refusing to overwrite")report.txt already exists -- refusing to overwrite
fresh.txt createdThis is the cheapest guard in the module. A run directory that should be fresh,
a result file that should be written once — "x" turns a silent clobber into a
loud failure.
4. Renaming Silently Overwrites
The fact worth the price of the chapter:
from pathlib import Path
Path("report.old").write_text("YESTERDAY -- the one good run this week\n")
Path("report.txt").write_text("today\n")
print("before:")
print(" report.old :", repr(Path("report.old").read_text()))
print(" report.txt :", repr(Path("report.txt").read_text()))
Path("report.txt").rename("report.old")
print("after:")
print(" report.old :", repr(Path("report.old").read_text()))
print(" report.txt exists:", Path("report.txt").exists())before:
report.old : 'YESTERDAY -- the one good run this week\n'
report.txt : 'today\n'
after:
report.old : 'today\n'
report.txt exists: FalseYesterday's report is gone. rename did not refuse, did not warn, and did
not return anything to check. The destination existed and was replaced.
This breaks the common "rotate the old one aside" idiom:
p.rename("report.old") # destroys any existing report.oldRun that nightly and report.old is never older than one day — the rotation
you thought you had does not exist.
The guarded version:
from pathlib import Path
Path("report.old").write_text("YESTERDAY\n")
Path("report.txt").write_text("today\n")
dest = Path("report.old")
if dest.exists():
print("refusing to rotate: report.old already exists")
else:
Path("report.txt").rename(dest)
print("report.old still:", repr(dest.read_text()))refusing to rotate: report.old already exists
report.old still: 'YESTERDAY\n'5. Deleting, and missing_ok
from pathlib import Path
p = Path("scratch.txt")
p.write_text("temporary\n")
print("exists:", p.exists())
p.unlink()
print("after unlink:", p.exists())
try:
p.unlink()
except FileNotFoundError as e:
print("second unlink:", type(e).__name__)
p.unlink(missing_ok=True)
print("with missing_ok: no error")exists: True
after unlink: False
second unlink: FileNotFoundError
with missing_ok: no errorunlink() removes a file. On a path that is not there it raises, which is
usually not what a cleanup step wants:
from pathlib import Path
for name in ["a.tmp", "c.tmp"]:
Path(name).write_text("x\n")
removed = 0
for name in ["a.tmp", "b.tmp", "c.tmp"]:
Path(name).unlink(missing_ok=True)
removed += 1
print("cleanup attempted on:", removed, "paths")
print("remaining:", [n for n in ["a.tmp", "b.tmp", "c.tmp"] if Path(n).exists()])cleanup attempted on: 3 paths
remaining: []Without missing_ok=True, b.tmp — already absent — would raise and c.tmp
would never be removed. A cleanup loop that aborts on the first already-clean
item leaves the rest dirty, which is the opposite of what it was written for.
unlink removes files only. A directory raises IsADirectoryError, and
directory removal is Module 18 Chapter 18.9's subject, deliberately — removing a
tree is a different risk.
6. Why Deletion Is Different
Three properties, none of which apply to a wrong read:
- No undo. There is no recycle bin. The bytes are gone, and on a scratch filesystem there is usually no backup either.
- No prompt.
rmasks sometimes;unlink()never does. There is no interactive safety net because there is no interaction. - It runs unattended. A nightly regression executes this code at 3am against paths built from a config file that someone edited yesterday.
The third amplifies the first two. A delete written for runs/seed_41 and
executed with an empty seed becomes a delete of runs/ — and nobody is watching.
7. Three Countermeasures
Name it precisely, and check what you built. A path assembled from parts can collapse if a part is empty:
from pathlib import Path
for seed in ["41", ""]:
target = Path("runs") / f"seed_{seed}" / "sim.log"
print(f"seed={seed!r:<5} -> {target}")
print(f" parts={len(target.parts)} name={target.name!r}")seed='41' -> runs/seed_41/sim.log
parts=3 name='sim.log'
seed='' -> runs/seed_/sim.log
parts=3 name='sim.log'Here the empty seed produced seed_ rather than something catastrophic, because
the f"seed_{seed}" wrapper kept a non-empty component. That is the defence:
never let an interpolated value be an entire path component on its own.
Delete only what you created. A script that removes files it wrote knows their names. A script that removes everything matching a pattern is trusting a pattern:
from pathlib import Path
written = []
for i in range(3):
p = Path(f"tmp_{i}.txt")
p.write_text("x\n")
written.append(p)
Path("do_not_touch.txt").write_text("someone else's\n")
for p in written:
p.unlink(missing_ok=True)
print("removed:", len(written))
print("survivor exists:", Path("do_not_touch.txt").exists())removed: 3
survivor exists: TruePrefer moving aside to removing. A rename into a quarantine name is reversible for as long as you leave it there, and Section 4's guard makes it safe:
from pathlib import Path
Path("suspect.log").write_text("possibly wanted\n")
src, dest = Path("suspect.log"), Path("suspect.log.discarded")
if dest.exists():
print("quarantine slot occupied -- leaving suspect.log alone")
else:
src.rename(dest)
print("moved aside:", dest.name, "->", repr(dest.read_text()))moved aside: suspect.log.discarded -> 'possibly wanted\n'8. touch() and the Sentinel File
touch() creates a file if it is absent and, crucially, leaves it alone if it
is not:
from pathlib import Path
d = Path("done.marker")
print("before :", d.exists())
d.touch()
print("after :", d.exists(), d.stat().st_size, "bytes")
d.touch()
print("again :", d.exists(), "-- still there")
keep = Path("has.txt")
keep.write_text("keepme\n")
keep.touch()
print("content:", repr(keep.read_text()))before : False
after : True 0 bytes
again : True -- still there
content: 'keepme\n'touch() does not truncate. That is the difference from write_text(""),
which empties an existing file — compare Section 2. Touching a file that already
has content updates its timestamp and leaves every byte in place.
The empty file it creates is the sentinel pattern, and it is everywhere in
flow automation: a zero-byte done.marker next to a run directory means that
run completed. The attraction is that the check costs nothing and cannot be
misread — a file either exists or does not, with no content to parse:
from pathlib import Path
Path("run_41").mkdir()
Path("run_42").mkdir()
Path("run_41/done.marker").touch()
def finished(run):
return (Path(run) / "done.marker").exists()
for run in ["run_41", "run_42"]:
print(f"{run}: {'complete' if finished(run) else 'INCOMPLETE or still running'}")run_41: complete
run_42: INCOMPLETE or still runningThis is Chapter 15.10's end-marker idea moved out of the log and into the filesystem, and it is more robust for exactly one reason: the marker is written last, by a step that only runs on success. A log's end marker can be absent because the tool was killed; a sentinel is absent for the same reason, but it cannot be accidentally present, because nothing else creates it.
The rule that makes it work:
Create the sentinel as the final action, after everything it attests to has completed.
A sentinel written at the start of a run attests to nothing at all.
9. Predict, Run, Observe
from pathlib import Path
a = Path("a.txt")
b = Path("b.txt")
a.write_text("AAA\n")
b.write_text("BBB\n")
print("A:", a.exists(), b.exists())
a.rename(b)
print("B:", a.exists(), b.exists())
print("C:", repr(b.read_text()))
b.unlink(missing_ok=True)
b.unlink(missing_ok=True)
print("D:", b.exists())A: True True
B: False True
C: 'AAA\n'
D: FalseB — a.txt is gone. A rename is a move, not a copy; there is no second
file afterwards.
C is the destructive one. b.txt now holds AAA. The BBB that was there
was overwritten with no error, no warning and no return value to inspect. If
b.txt had been the only copy of something, it is gone.
D — unlink(missing_ok=True) twice is fine. The second call had nothing to
do and said nothing about it, which is what makes cleanup loops writable.
10. Exercises
Exercise 1 — create and read. Use write_text and read_text on a small
file. Then say why you would not use them on a 14 GB log.
Exercise 2 — refuse to clobber. Write the same path twice with write_text
and record what survives. Then prevent it with "x" mode.
Exercise 3 — the rename trap. Create two files and rename one onto the other. Confirm the destination's contents are gone and that nothing raised.
Exercise 4 — guarded rotation. Write a rotate step that refuses when the destination exists. Run it twice and show the second run changes nothing.
Exercise 5 — missing_ok. Write a cleanup loop over three names where the
middle one is absent, without missing_ok. Record which files survive, then
fix it.
Exercise 6 — directories. Call unlink() on a directory and record the
error. Say which chapter handles that case.
Exercise 7 — the empty component. Build a path from an interpolated value
that is empty, and print .parts. Describe a wrapper that makes it safe.
Exercise 8 — three countermeasures. For a script that deletes last week's run directories, write one sentence each on how you would apply all three of Section 7's measures.
11. Summary
- These operations change the filesystem, so a mistake may leave nothing to inspect.
write_textandread_textare whole-file shorthands for awithblock — fine for small files, never for a log.write_textreplaces; there is no append form."x"mode refuses to overwrite and is the cheapest guard available.renamesilently overwrites its destination — the same property that makesos.replaceuseful for atomic publishing makes rotation destructive.- Guard a rename with an
exists()check when the destination matters. unlink()raises on a missing path;missing_ok=Trueis what lets a cleanup loop finish.- A cleanup loop that aborts on the first already-clean item leaves the rest dirty.
- Deletion has no undo, no prompt, and runs unattended — the third amplifies the first two.
touch()creates without truncating, unlikewrite_text("").- A zero-byte sentinel file written last is the cheapest possible record that a run completed — and it must be the final action to mean anything.
- Three countermeasures: never let an interpolated value be a whole path component, delete only what you created, and prefer moving aside to removing.
The last chapter of the module puts the whole of it to work on the question a regression script actually has to answer.
Where this fits
Part of the Python curriculum.
