Modern File Paths with pathlib
Reviewed & published by Brayan K
For years Python developers juggled file paths as fragile strings with os.path. pathlib replaced all of that with clean Path objects that know how to join, search, read, and write. It's the modern standard, and once you use it you won't go back.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
You'll build paths with the / operator, inspect filenames, find files with glob patterns, and read and write files in one line.
What You'll Learn in This Lesson
- Creating Path objects and the / join operator
- Inspecting paths: name, stem, suffix, parent
- Checking existence with exists, is_file, is_dir
- Reading and writing with read_text / write_text
- Finding files with glob and rglob
- Creating folders with mkdir(parents=True)
1 Paths as Objects, Not Strings
The old way joined strings by hand — brittle and platform-dependent. The new way uses the / operator, which reads exactly like a real path:
⚠️ Old: os.path with strings
import os
base = "/home/user"
path = os.path.join(base, "data", "log.txt")
# easy to forget join and break on Windows✅ New: pathlib with Path
from pathlib import Path
base = Path("/home/user")
path = base / "data" / "log.txt"
# reads like a path, works everywherefrom pathlib import Path
# Build paths with /
project = Path("myapp")
config = project / "settings" / "config.json"
print(config) # myapp/settings/config.json (or myapp\settings\... on Windows)
# Handy starting points:
print(Path.cwd()) # current working directory
print(Path.home()) # the user's home folder2 Inspecting a Path
A Path object exposes every part of a filename as a simple attribute — no more string slicing:
from pathlib import Path
p = Path("/home/user/reports/q3.final.csv")
print(p.name) # q3.final.csv (full filename)
print(p.stem) # q3.final (filename without LAST suffix)
print(p.suffix) # .csv (the extension)
print(p.suffixes) # ['.final', '.csv']
print(p.parent) # /home/user/reports (containing folder)
print(p.parts) # ('/', 'home', 'user', 'reports', 'q3.final.csv')
# Swap the extension easily
print(p.with_suffix(".json")) # /home/user/reports/q3.final.json| Attribute | For "data/report.csv" |
|---|---|
| .name | report.csv |
| .stem | report |
| .suffix | .csv |
| .parent | data |
🔍 Checking What Exists
Before reading or writing, you often need to know what's actually there. These return booleans:
from pathlib import Path
p = Path("notes.txt")
print(p.exists()) # True if the path exists at all
print(p.is_file()) # True if it exists AND is a file
print(p.is_dir()) # True if it exists AND is a folder
# A classic safe pattern:
if p.exists() and p.is_file():
print("Found the file, safe to read")
else:
print("File not found — create it or warn the user")These checks prevent the most common file errors. Checking exists() before reading turns a crash into a clean message.
3 Reading & Writing in One Line
For small text and data files, read_text and write_text handle opening and closing the file for you — no with open(...) boilerplate:
from pathlib import Path
note = Path("reminder.txt")
# Write (creates or overwrites the file)
note.write_text("Buy milk\nWalk the dog\n")
# Read the whole thing back as a string
content = note.read_text()
print(content)
# Buy milk
# Walk the dog
# For binary files (images, etc.) use the bytes versions:
# data = Path("logo.png").read_bytes()
# Path("copy.png").write_bytes(data)from pathlib import Path
# pathlib's "pure path" parts work without touching the disk,
# so this runs fine in the browser.
files = [
"invoices/2024/jan.pdf",
"invoices/2024/feb.PDF",
"photos/holiday.jpg",
"notes.txt",
]
print(f'{"File":15}{"Stem":10}{"Type":8}{"Folder"}')
print("-" * 45)
for f in files:
p = Path(f)
kind = p.suffix.lower().lstrip(".") or "(none)"
print(f'{p.name:15}{p.stem:10}{kind:8}{p.parent}')
# Count how many are PDFs (case-insensitive)
pdfs = [f for f in files if Path(f).suffix.lower() == ".pdf"]
print("\nPDF files:", len(pdfs))
# ✅ Expected output:
# File Stem Type Folder
# ---------------------------------------------
# jan.pdf jan pdf invoices/2024
# feb.PDF feb pdf invoices/2024
# holiday.jpg holiday jpg photos
# notes.txt notes txt .
#
# PDF files: 2🗂️ Finding Files with glob & rglob
Need every CSV in a folder? Every Python file in an entire project tree? glob and rglob make it trivial:
from pathlib import Path
folder = Path("project")
# glob: one folder only, using wildcard patterns
for csv in folder.glob("*.csv"):
print(csv.name)
# rglob: recursive — searches EVERY subfolder too
for py in folder.rglob("*.py"):
print(py)
# Combine with comprehensions for instant reports:
all_logs = list(folder.rglob("*.log"))
total_size = sum(f.stat().st_size for f in all_logs)
print(f"{len(all_logs)} log files, {total_size} bytes total")- * — any number of characters (*.txt)
- ? — exactly one character (file?.txt)
- [0-9] — one character from a set (log[0-9].txt)
- ** — any depth of folders (used internally by rglob)
4 Creating and Removing Folders
from pathlib import Path
out = Path("output/2024/reports")
# Create the whole chain of folders at once.
# parents=True -> make missing parent folders too
# exist_ok=True -> don't error if it already exists
out.mkdir(parents=True, exist_ok=True)
# Now write into it:
(out / "summary.txt").write_text("All good!")
# Rename / move:
old = Path("draft.txt")
# old.rename("final.txt")
# Delete a file:
# Path("temp.txt").unlink(missing_ok=True) # missing_ok avoids errors🧪 Worked Example — The Whole Lifecycle in One Run
The sections above showed each method on its own. This program strings them together the way a real script does: build a path, create the folders, write files, ask questions about them, read one back, search for them, and tidy up. It really does touch the disk, so run it and then run it a second time — thanks to exist_ok=True nothing breaks.
from pathlib import Path
# Everything happens inside one folder, which keeps the clean-up simple.
base = Path("workspace")
# ---- 1. BUILD PATHS WITH / (no string joining, no separators to get wrong) --
reports = base / "2024" / "reports"
print(reports)
# ---- 2. CREATE THE WHOLE CHAIN OF FOLDERS -----------------------------------
# parents=True -> also create the missing "workspace" and "2024" folders
# exist_ok=True -> running this a second time is fine, not a FileExistsError
reports.mkdir(parents=True, exist_ok=True)
print(reports.is_dir())
# ---- 3. WRITE FILES ---------------------------------------------------------
# write_text opens, writes and closes for you. It OVERWRITES every time.
(reports / "q1.csv").write_text("month,total\njan,120\n")
(reports / "q2.csv").write_text("month,total\napr,150\n")
(reports / "notes.txt").write_text("first draft\n")
# ---- 4. ASK QUESTIONS ABOUT A PATH -----------------------------------------
q1 = reports / "q1.csv"
print(q1.name) # file name with the extension
print(q1.stem) # name without the extension
print(q1.suffix) # extension, dot included
print(q1.parent) # the folder holding it
print(q1.exists(), q1.is_file()) # both True — it exists and it is a file
# ---- 5. READ IT BACK --------------------------------------------------------
# The file ends in a newline and print adds another, so you get a blank line.
print(q1.read_text())
# ---- 6. FIND FILES ----------------------------------------------------------
# glob searches ONE folder. sorted() because glob's order is not guaranteed —
# never depend on the order files come back in.
print(sorted(p.name for p in reports.glob("*.csv")))
# rglob searches every subfolder underneath, however deep.
print(sorted(str(p) for p in base.rglob("*.csv")))
# ---- 7. DERIVE A NEW PATH, THEN TIDY UP ------------------------------------
print(q1.with_suffix(".json")) # same path, different extension
(reports / "notes.txt").unlink() # delete that one file
print((reports / "notes.txt").exists()) # now False
# ✅ Expected output (on Linux/macOS — Windows shows \ instead of /):
# workspace/2024/reports
# True
# q1.csv
# q1
# .csv
# workspace/2024/reports
# True True
# month,total
# jan,120
#
# ['q1.csv', 'q2.csv']
# ['workspace/2024/reports/q1.csv', 'workspace/2024/reports/q2.csv']
# workspace/2024/reports/q1.json
# FalseThat blank line in the middle of the output is not a mistake: the CSV text ends with a newline and print adds one of its own. It is the commonest source of "why is there a gap in my output?" — print(text, end="") removes it.
🎯 Your Turn — Make a Folder, Fill It, Find It
Everything is written except the four pieces this lesson is about: the two mkdir arguments, the one-line write method, and the glob pattern.
from pathlib import Path
# 🎯 YOUR TURN — create a folder, save files in it, then find them again
# Fill in the blanks marked ___
base = Path("practice") / "logs" # "practice" does not exist yet
# 👉 blanks 1 and 2: the argument that creates missing PARENT folders, and the
# argument that makes a second run safe instead of raising FileExistsError
base.mkdir(___=True, ___=True)
# 👉 blank 3: the method that writes a string to a file in a single call
(base / "day1.log").___("started\nfinished\n")
(base / "day2.log").write_text("started\n")
# 👉 blank 4: the pattern matching every .log file in this one folder
found = sorted(p.name for p in base.glob("___"))
print(found)
# splitlines() drops the newline characters, so the list is easy to read
print((base / "day1.log").read_text().splitlines())
# ✅ Expected output:
# ['day1.log', 'day2.log']
# ['started', 'finished']A FileNotFoundError on the mkdir line means the parents argument is still blank — Python will not invent the missing practice folder for you unless you ask it to.
Real-World Example: An Auto-Filing Organiser
A genuinely useful script: scan a messy "Downloads" folder and sort every file into a subfolder by its extension. This is the kind of thing people actually run weekly:
from pathlib import Path
def organise(folder):
folder = Path(folder)
# Map extensions to destination folder names
buckets = {
".jpg": "Images", ".png": "Images", ".gif": "Images",
".pdf": "Documents", ".docx": "Documents", ".txt": "Documents",
".mp3": "Audio", ".wav": "Audio",
".zip": "Archives",
}
moved = 0
for item in folder.iterdir(): # loop over everything in the folder
if not item.is_file():
continue # skip subfolders
dest_name = buckets.get(item.suffix.lower(), "Other")
dest_dir = folder / dest_name
dest_dir.mkdir(exist_ok=True) # make the bucket folder if needed
item.rename(dest_dir / item.name) # move the file
moved += 1
print(f"Moved {item.name} -> {dest_name}/")
print(f"\nDone. Organised {moved} files.")
# organise("/home/user/Downloads")
# Moved photo.jpg -> Images/
# Moved report.pdf -> Documents/
# Moved song.mp3 -> Audio/
# Moved mystery.xyz -> Other/
# Done. Organised 4 files.Every line uses pathlib: iterdir to list, suffix to classify, / to build the destination, mkdir to create folders, rename to move. No string juggling anywhere.
from pathlib import Path
# Build a tidy output path and report each piece (pure-path, no disk).
base = Path("exports")
report_path = base / "2024" / "q3" / "sales_summary.csv"
print("Full path: ", report_path)
print("File name: ", report_path.name)
print("Stem: ", report_path.stem)
print("Extension: ", report_path.suffix)
print("Parent dir: ", report_path.parent)
print("Top folder: ", report_path.parts[0])
# Swap to a JSON version without rebuilding the path
json_path = report_path.with_suffix(".json")
print("As JSON: ", json_path)
# ✅ Expected output:
# Full path: exports/2024/q3/sales_summary.csv
# File name: sales_summary.csv
# Stem: sales_summary
# Extension: .csv
# Parent dir: exports/2024/q3
# Top folder: exports
# As JSON: exports/2024/q3/sales_summary.json🎯 Mini-Challenge: Sort a Messy Folder
You read the auto-filing organiser above. Now write one. The setup lines build the mess for you; the rest is an outline with no logic in it. This is a script people genuinely run on their own Downloads folder, so it is worth being able to write from memory.
# 🎯 MINI-CHALLENGE: sort a messy folder by file type
#
# Setup — copy these five lines exactly, they create the mess:
#
# from pathlib import Path
# messy = Path("downloads")
# messy.mkdir(exist_ok=True)
# for name in ["photo.jpg", "report.pdf", "song.mp3", "mystery.xyz"]:
# (messy / name).write_text("x")
#
# Now write the organiser:
# 1. buckets = {".jpg": "Images", ".pdf": "Documents", ".mp3": "Audio"}
# 2. for item in sorted(messy.iterdir()): # sorted() keeps it predictable
# 3. skip anything that is not a file (item.is_file())
# 4. dest_name = buckets.get(item.suffix.lower(), "Other")
# .get() with a default is what sends unknown types to "Other"
# 5. create the folder messy / dest_name with exist_ok=True
# 6. move the file: item.rename(that_folder / item.name)
# 7. print moved photo.jpg -> Images
# 8. After the loop, print the folders that now exist:
# print(sorted(p.name for p in messy.iterdir() if p.is_dir()))
#
# ✅ Expected output:
# moved mystery.xyz -> Other
# moved photo.jpg -> Images
# moved report.pdf -> Documents
# moved song.mp3 -> Audio
# ['Audio', 'Documents', 'Images', 'Other']
# your code hereWhy sorted(messy.iterdir()) rather than messy.iterdir()? Two reasons: the output order becomes predictable, and sorted reads the whole listing up front, so the folders you create inside the loop cannot confuse the loop that is creating them.
🧩 Reorder Challenge
These lines should make a folder and write a file inside it, but they're scrambled. Find the order:
A) out.mkdir(parents=True, exist_ok=True)
B) from pathlib import Path
C) (out / "hello.txt").write_text("hi")
D) out = Path("data/logs")Correct order: B → D → A → C
from pathlib import Path
out = Path("data/logs")
out.mkdir(parents=True, exist_ok=True)
(out / "hello.txt").write_text("hi")Import, build the Path, create the folder (with parents so data/ is made too), then write the file. You must create the folder before writing into it.
🧠 Quick Recall — Predict the Output
from pathlib import Path
print(Path("a") / "b" / "c.txt")a/b/c.txt (on Windows: a\b\c.txt) — the / operator joins path parts using the OS separator.
from pathlib import Path
print(Path("archive.tar.gz").suffix).gz — .suffix is only the LAST extension. To get both you'd use .suffixes, which returns ['.tar', '.gz'].
from pathlib import Path
print(Path("/data/notes/today.md").stem)today — .stem is the filename without its extension and without the folder. (.name would be today.md.)
❓ Frequently Asked Questions
Files and folders bend to your will now!
You can build paths with the / operator, inspect every part of a filename, check existence, read and write in one line, find files with glob, and create folders safely. pathlib is the modern way and you've mastered the essentials.
Practice quiz
What does Path("a") / "b" / "c.txt" produce on Linux?
- a/b/c.txt
- a\b\c.txt
- abc.txt
- a.b.c.txt
Answer: a/b/c.txt. The / operator joins path parts with the OS separator — forward slashes on Linux/Mac.
What is Path("archive.tar.gz").suffix?
- .tar.gz
- .tar
- .gz
- tar.gz
Answer: .gz. .suffix returns only the LAST extension. Use .suffixes to get ['.tar', '.gz'].
What is Path("/data/notes/today.md").stem?
- today.md
- today
- notes
- .md
Answer: today. .stem is the filename without its final extension and without the folder.
What is Path("/home/user/reports/q3.final.csv").name?
- q3
- q3.final
- q3.final.csv
- reports
Answer: q3.final.csv. .name is the full final component, including all extensions.
Which attribute gives the containing folder of a path?
- .parent
- .stem
- .suffix
- .name
Answer: .parent. For 'data/report.csv', .parent is 'data' — the folder holding the file.
What does Path("/data/report.csv").with_suffix(".json") return?
- /data/report.json.csv
- /data/report.json
- /data/.json
- report.json
Answer: /data/report.json. with_suffix swaps the last extension, giving /data/report.json.
Which method searches recursively through ALL subfolders for a pattern?
- glob
- iterdir
- rglob
- walk
Answer: rglob. rglob('*.py') recurses into every subfolder; glob only looks in one folder.
Which call creates a folder chain safely without erroring if it already exists?
- p.mkdir()
- p.mkdir(parents=True, exist_ok=True)
- p.makedirs()
- p.create(recursive=True)
Answer: p.mkdir(parents=True, exist_ok=True). parents=True makes missing parents (like mkdir -p); exist_ok=True avoids FileExistsError.
Which one-line call returns a file's whole contents as a string?
- p.read()
- p.read_text()
- p.text()
- p.open_text()
Answer: p.read_text(). read_text() opens, reads, and closes the file, returning a str — no with-open needed.
What does p.write_text("hi") do if the file already exists?
- Appends to the end
- Raises FileExistsError
- Overwrites the entire file
- Does nothing
Answer: Overwrites the entire file. write_text overwrites the whole file every time. Use p.open('a') to append instead.
Continue this course
- Previous: itertools
- Next: Checkpoint: A Real-World Script — Combine APIs, dates, regex and CSV into one real script — then a quiz
- Quick reference: Python cheat sheet › Files & with
Frequently asked questions
What is pathlib?
pathlib is the modern, built-in Python module for working with filesystem paths as objects instead of strings. A Path object knows how to join, split, check existence, read, and write — replacing the older os.path string functions.
Why use pathlib instead of os.path?
pathlib is more readable and less error-prone. Joining paths with the / operator (base / 'data' / 'file.txt') is clearer than os.path.join, and methods like .exists(), .read_text(), and .glob() live right on the object. It also handles Windows and Unix path separators automatically.
What is the difference between / and joinpath in pathlib?
They do the same thing. path / 'sub' / 'file.txt' uses the overloaded division operator, while path.joinpath('sub', 'file.txt') is the method form. The / operator is the idiomatic, most-used style.
How do I find all files matching a pattern with pathlib?
Use .glob('*.txt') for files in one folder, or .rglob('*.txt') to search recursively through all subfolders. Both return a generator of Path objects you can loop over.
What is the difference between .name, .stem, and .suffix?
For a path like 'report.final.csv': .name is the full filename 'report.final.csv', .stem is the name without the last extension 'report.final', and .suffix is the last extension '.csv'. .parent gives the containing folder.