Paths and Directories with pathlib
pathlib turns file paths into objects with methods. It replaces string joining and most of os.path, and it works the same on Windows, macOS and Linux.
- Why not strings
- Building paths
- Taking a path apart
- Asking about a path
- Reading and writing in one line
- Creating and removing
- Renaming and moving
- Listing a directory
- Matching by pattern
- Worked examples
- Total size of a folder
- Counting files by extension
- Organising files into folders by type
- Finding recently changed files
- Temporary files
- pathlib and os.path
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Why not strings
import os
# Fragile: separators, duplicated slashes, platform differences
path = "data" + "/" + "reports" + "/" + "q3.txt"
# Better, but still a function call per operation
path = os.path.join("data", "reports", "q3.txt")
# Best
from pathlib import Path
path = Path("data") / "reports" / "q3.txt"
print(path) # data/reports/q3.txt (or with backslashes on Windows)pathlib overloads the / operator to join path segments. Python inserts the correct separator for the platform, so the same code runs everywhere.
Building paths
from pathlib import Path
print(Path.cwd()) # the current working directory
print(Path.home()) # the user home directory
print(Path("notes.txt").resolve()) # the full absolute path
base = Path("project")
print(base / "src" / "main.py")
print(base.joinpath("docs", "readme.md"))
print(Path("~/notes").expanduser()) # expands the home shortcutTaking a path apart
path = Path("/home/meera/reports/q3-summary.tar.gz")
print(path.name) # q3-summary.tar.gz - file name with extension
print(path.stem) # q3-summary.tar - name without the LAST suffix
print(path.suffix) # .gz - the last extension only
print(path.suffixes) # ['.tar', '.gz']
print(path.parent) # /home/meera/reports
print(path.parents[1]) # /home/meera
print(path.parts) # ('/', 'home', 'meera', 'reports', 'q3-summary.tar.gz')
print(path.is_absolute()) # Truepath = Path("report.txt")
print(path.with_suffix(".md")) # report.md
print(path.with_name("summary.txt")) # summary.txt
print(path.with_stem("final")) # final.txt (Python 3.9+)Asking about a path
path = Path("notes.txt")
print(path.exists())
print(path.is_file())
print(path.is_dir())
print(path.is_symlink())
if path.exists():
info = path.stat()
print(info.st_size, "bytes")
from datetime import datetime
print(datetime.fromtimestamp(info.st_mtime))Reading and writing in one line
path = Path("notes.txt")
path.write_text("first line\nsecond line\n", encoding="utf-8")
print(path.read_text(encoding="utf-8"))
path.write_bytes(b"\x00\x01")
print(path.read_bytes())
# For anything larger, open it properly
with path.open(encoding="utf-8") as handle:
for line in handle:
print(line.rstrip())read_text and write_text load the whole file at once. They are ideal for configuration and small data, and wrong for large files.
Creating and removing
folder = Path("data/reports/2026")
folder.mkdir(parents=True, exist_ok=True) # create the whole chain, no error if present
file = folder / "q3.txt"
file.touch() # create an empty file if absent
file.unlink() # delete a file
file.unlink(missing_ok=True) # no error if it was not there
folder.rmdir() # only works on an EMPTY directory
import shutil
shutil.rmtree("data") # delete a directory and everything in itshutil.rmtree deletes recursively with no confirmation and no undo. Print what you are about to delete before you run it, especially when the path is built from a variable.Renaming and moving
source = Path("draft.txt")
source.rename("final.txt") # fails if final.txt exists, on Windows
import os
os.replace("draft.txt", "final.txt") # overwrites silently, atomic
import shutil
shutil.move("final.txt", "archive/") # works across filesystems
shutil.copy("final.txt", "backup.txt") # copy contents
shutil.copy2("final.txt", "backup.txt") # copy contents and timestampsListing a directory
folder = Path("project")
for item in folder.iterdir(): # one level only
kind = "dir " if item.is_dir() else "file"
print(kind, item.name)
print(sorted(p.name for p in folder.iterdir() if p.is_file()))Matching by pattern
folder = Path("project")
for path in folder.glob("*.py"): # this directory only
print(path)
for path in folder.glob("**/*.py"): # every subdirectory as well
print(path)
for path in folder.rglob("*.py"): # the same thing, shorter
print(path)
for path in folder.glob("test_*.py"):
print(path)| Pattern | Matches |
|---|---|
* | Any run of characters within one name |
? | Exactly one character |
[abc] | One character from the set |
** | This directory and all below it |
Worked examples
Total size of a folder
def folder_size(folder):
return sum(p.stat().st_size for p in Path(folder).rglob("*") if p.is_file())
size = folder_size("project")
print(f"{size:,} bytes ({size / 1024 / 1024:.2f} MB)")Counting files by extension
from collections import Counter
def extension_report(folder):
counts = Counter(
p.suffix.lower() or "(none)"
for p in Path(folder).rglob("*")
if p.is_file()
)
for extension, count in counts.most_common():
print(f"{extension:<10}{count:>5}")
extension_report(".")Organising files into folders by type
GROUPS = {
"images": {".png", ".jpg", ".jpeg", ".gif"},
"documents": {".pdf", ".txt", ".md", ".docx"},
"archives": {".zip", ".tar", ".gz"},
}
def organise(folder, dry_run=True):
folder = Path(folder)
for path in folder.iterdir():
if not path.is_file():
continue
for group, extensions in GROUPS.items():
if path.suffix.lower() in extensions:
target = folder / group
print(f"{path.name} -> {group}/")
if not dry_run:
target.mkdir(exist_ok=True)
path.rename(target / path.name)
break
organise("downloads", dry_run=True)Note the dry_run default. Any script that moves or deletes files should show what it intends to do before it does it.
Finding recently changed files
import time
def recent(folder, hours=24):
cutoff = time.time() - hours * 3600
return [
p for p in Path(folder).rglob("*")
if p.is_file() and p.stat().st_mtime > cutoff
]
for path in recent(".", hours=1):
print(path)Temporary files
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as name:
folder = Path(name)
(folder / "scratch.txt").write_text("working", encoding="utf-8")
print(list(folder.iterdir()))
# the whole directory is deleted here
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as handle:
handle.write("data")
temp_path = Path(handle.name)
print(temp_path.read_text())
temp_path.unlink()pathlib and os.path
os.path | pathlib |
|---|---|
os.path.join(a, b) | Path(a) / b |
os.path.exists(p) | Path(p).exists() |
os.path.basename(p) | Path(p).name |
os.path.dirname(p) | Path(p).parent |
os.path.splitext(p)[1] | Path(p).suffix |
os.listdir(p) | Path(p).iterdir() |
os.makedirs(p, exist_ok=True) | Path(p).mkdir(parents=True, exist_ok=True) |
os.remove(p) | Path(p).unlink() |
os.walk(p) | Path(p).rglob("*") |
Every function that accepts a path string also accepts a Path, so the two can be mixed freely while migrating.
Common mistakes
- Building paths by concatenating strings with
/or\\. - Expecting
stemto strip every suffix fromarchive.tar.gz. - Calling
rmdiron a directory that is not empty. - Using
rglob("*")on a huge tree without filtering, and waiting a long time. - Writing a file organiser with no dry run mode.
- Checking
exists()and then opening, when the file could vanish in between.
Best practices
- Use
pathlibfor all new code. - Build paths with
/, never with string concatenation. - Use
mkdir(parents=True, exist_ok=True)rather than checking first. - Give every destructive script a dry run mode that is the default.
- Use
tempfilefor scratch files rather than writing into the current directory.
Practice
- Report the five largest files under a directory tree.
- Find every file changed in the last day and group them by extension.
- Write a function that safely creates a nested folder structure and reports what it created.
- Rename every
.txtfile in a folder to.md, with a dry run mode. - Explain why
Path("a.tar.gz").stemis"a.tar"and write a function returning"a".
Conclusion
Treat a path as an object rather than a string. pathlib gives you joining with /, the parts by name, existence checks, directory creation, pattern matching with glob and recursive walking with rglob - all of it identical across platforms.