File and Folder Automation with pathlib
Find, sort, and plan file changes safely
In this lesson
pathlib is Python’s modern, cross-platform way to point at files and folders and read their parts — without wrestling with slashes — and most automation eventually touches files. Here you’ll parse filenames, sort them by type, and build a dry-run plan you can trust before moving anything.
Explain it like I’m 5
A Path is a smart label for a file or folder. Instead of chopping up strings yourself, you ask the label questions: what’s your name? your extension? what folder are you in?
Paths as objects, not strings
Path turns a path into an object you can question. The handy parts are .name (the full filename), .stem (the name without its extension), and .suffix (the extension, including the dot). Reading just the parts needs no real file on disk, so it works anywhere.
from pathlib import Path
p = Path("photos/vacation.JPG")
print(p.name)
print(p.stem)
print(p.suffix)
print(p.suffix.lower())
vacation.JPG vacation .JPG .jpg
Reading file content
Once you have a Path, opening and reading it is straightforward. path.read_text() loads the whole file as a string in one call — convenient for small config files or templates. For anything line-by-line, path.open() gives you a file object you can loop, and Python reads one line at a time so the whole file is never in memory at once.
from pathlib import Path
# Simulate writing a file so this runs in the sandbox:
p = Path("notes.txt")
p.write_text("line one\nline two\nline three\n")
# read_text() loads the whole file into a string:
content = p.read_text()
print(repr(content))
'line one\nline two\nline three\n'
from pathlib import Path
p = Path("notes.txt")
p.write_text("line one\nline two\nline three\n")
# path.open() lets you loop line by line:
with p.open("r", encoding="utf-8") as f:
for line in f:
print(line.strip())
line one line two line three
Open notes.txt with path.open() and print each line stripped of whitespace. The file is written for you here — on a real machine you'd just point Path at an existing file and the rest of the code would be identical.
from pathlib import Path
# Written here to stand in for a file already on disk.
p = Path("notes.txt")
p.write_text("alpha\nbeta\ngamma\n")
# TODO: open p and print each line stripped of whitespace
Use with p.open("r", encoding="utf-8") as f:, then loop for line in f: and call line.strip() before printing.
from pathlib import Path
# Written here to stand in for a file already on disk.
p = Path("notes.txt")
p.write_text("alpha\nbeta\ngamma\n")
with p.open("r", encoding="utf-8") as f:
for line in f:
print(line.strip())
alpha
beta
gamma
Group files by extension
To file every document into a folder named after its type, derive the folder from each file’s suffix. Lower-casing and stripping the dot turns .JPG into a clean folder name like jpg.
from pathlib import Path
files = ["a.txt", "b.jpg", "notes.txt", "pic.JPG"]
for name in files:
folder = Path(name).suffix.lower().lstrip(".") or "other"
print(f"{name} -> {folder}/{name}")
a.txt -> txt/a.txt b.jpg -> jpg/b.jpg notes.txt -> txt/notes.txt pic.JPG -> jpg/pic.JPG
Always dry-run before you move anything
Finding files on a real machine uses globbing — matching by pattern, like every .txt in a folder. Whether you find files by glob or get them as a list, the rule is the same: print the plan first, move second.
from pathlib import Path
# On your computer this lists every .txt file in the current folder:
for path in Path(".").glob("*.txt"):
print(path.name)
Finish organize_plan so it returns a list of "name -> folder/name" strings, filing each file into a folder named after its lowercase extension. Don't move anything.
from pathlib import Path
def organize_plan(files):
plan = []
for name in files:
folder = "" # TODO: lowercase extension without the dot, e.g. "txt"
plan.append(f"{name} -> {folder}/{name}")
return plan
print(organize_plan(["report.PDF", "photo.jpg"]))
Path(name).suffix gives ".PDF"; chain .lower().lstrip(".") to get "pdf".
from pathlib import Path
def organize_plan(files):
plan = []
for name in files:
folder = Path(name).suffix.lower().lstrip(".")
plan.append(f"{name} -> {folder}/{name}")
return plan
print(organize_plan(["report.PDF", "photo.jpg"]))
['report.PDF -> pdf/report.PDF', 'photo.jpg -> jpg/photo.jpg']
Common mistake: Running a destructive change on the wrong folder
A single wrong path, or running from the wrong directory, points the script at files you didn’t mean to touch.
Print the resolved folder and the plan first, and start with a dry run. Confirm the target is what you expect before any move or delete.
Common mistake: Assuming every file has an extension
Files like README or Makefile have an empty .suffix, which breaks code that expects a real type.
Handle the empty case explicitly — for example suffix.lstrip(".") or "other" — so extension-less files still go somewhere sensible.
Common mistake: Building paths by gluing strings together
Writing folder + "/" + name seems simple but bakes in one operating system’s slash and misses edge cases.
Join paths with the / operator on Path objects (Path(folder) / name); it’s readable and works on every platform.
What does Path("a/b.txt").suffix return?
.suffix is the extension including its dot: .txt. (.stem would give b.)
Why run a dry-run before renaming or moving files?
A dry run prints the plan so you can confirm it before anything irreversible happens.
What does globbing do?
Globbing matches files by pattern — *.txt finds every text file in a folder.
Mini exercise (medium)
Write count_by_type(files) that takes a list of filenames and returns a dict mapping each lowercase extension (without the dot) to how many files have it. Files with no extension should count under "none".
Do it here. Finish the code below and press Run to try your answer right in the browser — no setup needed.
from pathlib import Path
def count_by_type(files):
counts = {}
for name in files:
ext = "" # TODO: lowercase extension without the dot, or "none" if there isn't one
# TODO: add 1 to counts[ext]
return counts
print(count_by_type(["a.txt", "b.TXT", "c.jpg", "README"]))
For each name, compute ext = Path(name).suffix.lower().lstrip(".") or "none", then counts[ext] = counts.get(ext, 0) + 1.
from pathlib import Path
def count_by_type(files):
counts = {}
for name in files:
ext = Path(name).suffix.lower().lstrip(".") or "none"
counts[ext] = counts.get(ext, 0) + 1
return counts
print(count_by_type(["a.txt", "b.TXT", "c.jpg", "README"]))
{'txt': 2, 'jpg': 1, 'none': 1}
assert count_by_type(["x.PNG"]) == {"png": 1}, "extensions should be lowercased and stripped of the dot"
assert count_by_type(["readme"]) == {"none": 1}, "files with no extension should count under 'none'"
assert count_by_type(["a.txt", "b.TXT"]) == {"txt": 2}, "a.txt and b.TXT are both txt"
print("✓ Counted by type!")