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())
Output
True

Plain 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())
Output
True
  • shutil.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*"))])
Output
['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())
Output
False
  • Path.unlink() deletes a file. missing_ok=True makes deleting a file that isn't there a no-op instead of a FileNotFoundError.
  • 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)
Output
bytes: 363
changed this year: True

st_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")
Output
archived 4 notes
archive: 5 files, 1740 bytes

copytree 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.

  1. Write ensure_folder(path) that makes a folder and its parents exist, and returns the Path.
  2. Write archive_note(title) that moves a title's note from notes/ into notes/archive/, returning True if it moved one and False if there was nothing to move.
  3. 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())
Output
exports/2026 True

2. 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
Output
True
False

3. 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"))
Output
1433

is_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.