Automating file operations
Use pathlib to find files by pattern and shutil to copy, move, or rename them in bulk.
- Use pathlib.Path.glob() to iterate over files matching a pattern
- Construct output paths relative to an input path
- Copy a file with shutil.copy() and move or rename one with shutil.move()
Now that you understand paths, you can write code that finds files automatically and
does something with each one. The two modules you need are already in the standard
library: pathlib for finding and building paths, and shutil for copying, moving,
and deleting files.
Finding files with glob
Path.glob() takes a pattern and yields every matching path in that directory:
from pathlib import Path
data_dir = Path("data")
# All .csv files directly inside data/
for csv_file in data_dir.glob("*.csv"):
print(csv_file)
# All .csv files anywhere inside data/ and its subdirectories
for csv_file in data_dir.glob("**/*.csv"):
print(csv_file)The ** pattern means "any number of directory levels." This is the same glob
syntax used in shells and .gitignore files.
Building output paths
A common pattern: for each input file, construct a corresponding output path that keeps the same filename but lands in a different directory:
from pathlib import Path
input_dir = Path("raw")
output_dir = Path("processed")
output_dir.mkdir(exist_ok=True) # create if it doesn't exist
for f in input_dir.glob("*.csv"):
out = output_dir / f.name # same filename, different directory
print(f"Would copy {f} -> {out}")f.name is just the filename without the directory part. output_dir / f.name
joins them cleanly without any string concatenation.
Copying and moving with shutil
import shutil
from pathlib import Path
src = Path("data/report.csv")
dst = Path("archive/report_backup.csv")
shutil.copy(src, dst) # copy src to dst (creates dst)
shutil.move(src, dst) # move (rename) src to dstshutil.copy copies the file contents. shutil.move moves the file — equivalent
to a rename if source and destination are on the same filesystem.
shutil.move on a destination that already exists will silently overwrite it on
most platforms. Check dst.exists() first if you want to avoid clobbering files.
Try it
The runner below demonstrates the path-building and glob logic using in-memory string paths — no real filesystem needed:
PurePosixPath works identically to Path for building and inspecting paths —
it just doesn't touch the real filesystem, which is why it works in the browser runner.
A bulk rename pattern
Here is the complete shape of a bulk rename script — find every draft file, rename it to final:
from pathlib import Path
target_dir = Path("documents")
for f in target_dir.glob("draft_*.txt"):
new_name = f.with_name(f.name.replace("draft_", "final_"))
f.rename(new_name)
print(f"renamed {f.name} -> {new_name.name}")f.with_name(...) creates a new path in the same directory with a different
filename — cleaner than rebuilding the path from scratch.
Where to go next
Next: what makes a good script — before writing a full script, understand the four properties that separate scripts you trust from scripts that cause surprises.
Paths and directories
The filesystem is a tree. Knowing absolute paths from relative ones — and using pathlib — keeps your scripts portable.
What makes a good script?
Four properties separate scripts you trust from ones that cause surprises — idempotency, dry-run mode, clear output, and graceful errors.