Chapter 9
Moving, Copying, and Deleting
Reading and writing files is most of the work. The rest is housekeeping: making
the folder a file needs to live in, copying it somewhere for safety, moving it
when things get reorganized, and throwing it out when it's done. pathlib and
shutil between them cover all of it.
Making a folder
from pathlib import Path
Path("exports/2026").mkdir(parents=True, exist_ok=True)
print(Path("exports/2026").is_dir())
TruePlain mkdir() fails if the folder already exists, and also if its parent
doesn't. The two arguments fix each case. parents=True creates any missing
folders above it, and exist_ok=True makes "it's already there" not an error.
Together they mean "make sure this folder exists," which is almost always what
you want.
Copying
import shutil
from pathlib import Path
shutil.copy2("notes/piranesi.txt", "notes/piranesi-backup.txt")
print(Path("notes/piranesi-backup.txt").exists())
Trueshutil.copy2(src, dst)copies the file and its timestamps. This is the one to reach for by default.shutil.copyfile(src, dst)copies only the bytes, no timestamps or permissions.shutil.copytree(src, dst)copies a whole folder and everything under it.
dst can be a folder, in which case the file keeps its name, or a full path, in
which case it's copied and renamed in one step.
Moving and renaming
Moving and renaming are the same operation: you're changing a file's path.
from pathlib import Path
Path("notes/piranesi-backup.txt").rename("notes/piranesi-old.txt")
print([p.name for p in sorted(Path("notes").glob("piranesi*"))])
['piranesi-old.txt', 'piranesi.txt']Path.rename(target)renames or moves a file, as long as the target is on the same drive.os.replace(src, dst)does the same but overwrites the target without complaint. This is the primitive behind the safe-replace pattern from Chapter 3.shutil.move(src, dst)works even across drives, by copying and then deleting, and accepts a folder as the destination.
Python 3.14 adds Path.copy() and Path.move() methods. On 3.11 through 3.13,
which is what this book targets, use shutil for both.
Deleting
from pathlib import Path
target = Path("notes/piranesi-old.txt")
target.unlink(missing_ok=True)
target.unlink(missing_ok=True) # already gone, no error
print(target.exists())
FalsePath.unlink()deletes a file.missing_ok=Truemakes deleting a file that isn't there a no-op instead of aFileNotFoundError.Path.rmdir()deletes a folder, but only if it's empty.shutil.rmtree(path)deletes a folder and everything inside it. No prompt, no recycle bin, no undo.
rmtree is the one to be careful with. The classic accident is
shutil.rmtree(base_dir / user_value) where user_value comes out empty and you
delete base_dir itself. Print the path, or check that it's under a folder you
expect, before you call it.
Metadata
Metadata is the set of facts about a file that aren't its contents: its size,
when it was last changed, its permissions. Path.stat() returns them all:
from pathlib import Path
from datetime import datetime
info = Path("notes/piranesi.txt").stat()
print("bytes:", info.st_size)
changed = datetime.fromtimestamp(info.st_mtime)
print("changed this year:", changed.year == datetime.now().year)
bytes: 363
changed this year: Truest_size is the size in bytes. st_mtime is the last-modified time as a number
of seconds, which datetime.fromtimestamp turns into a real date you can compare
and format.
Tidying the notes folder
The running-example job: move every finished book's note into an archive folder,
then report on it. We'll do the work on a copy of notes/ so the original is
still there for the rest of the chapter.
import csv
import shutil
from pathlib import Path
shutil.copytree("notes", "library")
archive = Path("library/archive")
moved = 0
with open("reading-log.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if not row["finished"]:
continue
stem = row["title"].lower().replace(" ", "-")
note = Path("library") / f"{stem}.txt"
if note.exists():
shutil.move(note, archive / note.name)
moved += 1
print(f"archived {moved} notes")
files = sorted(archive.glob("*.txt"))
total = sum(p.stat().st_size for p in files)
print(f"archive: {len(files)} files, {total} bytes")
archived 4 notes
archive: 5 files, 1740 bytescopytree duplicated the folder, then four notes moved into its archive, joining
the one that was already there. csv.DictReader from Chapter 7 read the titles,
Path from Chapter 5 built the note paths, and shutil.move did the work.
Common mistakes
rename across drives. Path.rename raises OSError: Invalid cross-device
link when the source and target are on different filesystems. shutil.move
handles that case; rename doesn't.
rename versus replace when the target exists. On Windows, Path.rename
raises if the target file already exists. os.replace overwrites it. Use
os.replace when overwriting is what you mean.
rmdir on a folder with anything in it. That's an OSError. Emptying a
folder tree is shutil.rmtree, used carefully.
Writing into a folder that doesn't exist yet. open("exports/2026/x.csv",
"w") raises FileNotFoundError if exports/2026 isn't there. mkdir(parents=True,
exist_ok=True) first.
Practice
Try each of these before you read the solution under it.
- Write
ensure_folder(path)that makes a folder and its parents exist, and returns thePath. - Write
archive_note(title)that moves a title's note fromnotes/intonotes/archive/, returningTrueif it moved one andFalseif there was nothing to move. - Write
folder_size(path)that returns the total bytes of the files directly inside a folder, not counting subfolders.
Solutions
1.
from pathlib import Path
def ensure_folder(path):
p = Path(path)
p.mkdir(parents=True, exist_ok=True)
return p
exports = ensure_folder("exports/2026")
print(exports.as_posix(), exports.is_dir())
exports/2026 True2. Build both paths, check the source, move it.
import shutil
from pathlib import Path
def archive_note(title):
stem = title.lower().replace(" ", "-")
note = Path("notes") / f"{stem}.txt"
if not note.exists():
return False
archive = Path("notes/archive")
archive.mkdir(parents=True, exist_ok=True)
shutil.move(note, archive / note.name)
return True
Path("notes/dune.txt").write_text("Dune notes\n", encoding="utf-8")
print(archive_note("Dune"))
print(archive_note("Dune")) # already moved
True
False3. Sum st_size over the files, skipping any subfolders.
from pathlib import Path
def folder_size(path):
return sum(p.stat().st_size for p in Path(path).iterdir() if p.is_file())
print(folder_size("notes"))
1433is_file() skips the archive subfolder, so this counts only the notes sitting
directly in notes/.
Where this leaves you
You can create folders, copy and move files with shutil, delete them with
pathlib, and read a file's size and modification time. That's the housekeeping
half of file handling. Chapter 10 goes back to reading and writing, but for files
that aren't text at all.