Why this matters
Real programs work with more than one file: process every .csv in a folder, write output next to the input, create a directory for results, skip files that are too big, check whether a config exists before reading it. Doing this with string manipulation (folder + "/" + name) is fragile: separators differ between Windows and Unix, and slicing off extensions by hand goes wrong on names like archive.tar.gz. The standard library's pathlib module represents paths as objects with methods for every common operation. It is the modern, recommended way to handle the file system in Python.
A quick model of the file system
On Linux and macOS, all files live in one tree rooted at /. A path names a location in that tree:
/
├── etc/
│ └── hostname
└── home/
└── ann/
├── notes.txt
└── reports/
└── 2026.csv
- An absolute path starts at the root:
/home/ann/reports/2026.csv. - A relative path is resolved against the process's current working directory (cwd): if the cwd is
/home/ann, thenreports/2026.csvmeans the same file..is the current directory and..its parent.
A directory is itself a special file listing names and the inodes (on-disk records holding size, permissions, timestamps and data location) they refer to. Listing a directory reads those entries; their order is whatever the file system keeps internally.
Path objects
from pathlib import Path
p = Path("/home/ann/reports/2026.csv")
print(p.name) # 2026.csv
print(p.stem) # 2026
print(p.suffix) # .csv
print(p.parent) # /home/ann/reports
print(p.parts) # ('/', 'home', 'ann', 'reports', '2026.csv')
from pathlib import Path imports just the Path name from the module. On Linux, Path(...) creates a PosixPath; on Windows a WindowsPath with backslash handling. Your code is the same on both.
Creating a Path does not touch the disk: it is just a value describing a location, which may or may not exist. Methods like exists() or read_text() are where the system calls happen.
Building paths with /
The / operator joins path components, inserting the right separator:
base = Path("data")
out = base / "results" / "summary.txt"
print(out) # data/results/summary.txt
print(Path.home() / ".config") # /home/ann/.config
This is operator overloading: Path defines what / means for its objects (module 12 shows how). Joining with an absolute path on the right discards the left side, as the operating system would: Path("data") / "/etc" is /etc.
Changing parts
p = Path("reports/2026.csv")
p.with_suffix(".json") # reports/2026.json
p.with_name("2025.csv") # reports/2025.csv
p.with_stem("2026-final") # reports/2026-final.csv
Paths are immutable; these return new Path objects. Convert to a plain string with str(p) when an API needs one (most of the standard library, including open, accepts Path objects directly).
Asking the file system
p = Path("notes.txt")
p.exists() # True if anything is there
p.is_file() # True if it is a regular file
p.is_dir() # True if it is a directory
p.stat().st_size # size in bytes (a stat() system call)
p.resolve() # absolute path with symlinks and .. resolved
Path.cwd() # the current working directory
stat() returns the inode metadata: st_size, st_mtime (last modification time as seconds since 1970), permissions in st_mode, and more. It raises FileNotFoundError if the path does not exist.
Reading and writing whole files
For small files, Path has one-call shortcuts that open, read or write, and close:
config = Path("settings.txt")
config.write_text("theme=dark\n", encoding="utf-8") # creates or overwrites
text = config.read_text(encoding="utf-8")
print(text.strip()) # theme=dark
raw = Path("logo.png").read_bytes() # bytes, for binary files
write_text behaves like open(..., "w"): it overwrites. For line-by-line work on big files, or appending, use p.open(...) (same arguments as open) in a with statement as in the last lesson.
Listing directories
iterdir() yields the entries directly inside a directory, as Path objects:
for entry in sorted(Path("docs").iterdir()):
kind = "dir " if entry.is_dir() else "file"
print(kind, entry.name)
file a.txt
file b.md
file notes.txt
dir sub
Always sort when order matters. iterdir and glob yield entries in file-system order, which differs between file systems and machines. Unsorted output is a classic source of "works on my machine" test failures.
Pattern matching with glob
glob(pattern) yields paths matching a shell-style pattern:
| Pattern | Matches |
|---|---|
*.txt | names ending in .txt in this directory |
data_??.csv | data_ + exactly two characters + .csv |
[ab]* | names starting with a or b |
**/*.py | .py files in this directory and all subdirectories |
for csv_file in sorted(Path("reports").glob("*.csv")):
print(csv_file.name)
py_count = sum(1 for _ in Path(".").rglob("*.py")) # rglob = glob("**/" + pattern)
Like the other tools in this module, glob returns a lazy generator: wrap it in sorted() or list() if you need to use the results more than once.
Creating, moving and deleting
out = Path("output/2026/november")
out.mkdir(parents=True, exist_ok=True) # create with missing parents; fine if it exists
src = Path("draft.txt")
src.rename(out / "final.txt") # move or rename (same file system)
(out / "final.txt").unlink() # delete a file
out.rmdir() # delete an empty directory
mkdir()withoutparents=Truefails withFileNotFoundErrorif the parent is missing, and withoutexist_ok=TrueraisesFileExistsErrorif the directory exists.renameis therename()system call: it changes a directory entry and moves no data, so it is instant even for huge files, but only works within one file system.shutil.movecopies across file systems.unlinkdeletes (the name comes from the system call: it removes a directory link to the inode; the data is freed when no links and no open descriptors remain). There is no trash can; it is gone.- To delete a whole directory tree, use
shutil.rmtree(path), carefully.
Warning: Do not add or remove entries in a directory while iterating it with
iterdir(); the result is undefined. Collect the paths into a list first, then act on them.
Common mistakes
Building paths with string concatenation. folder + "/" + name breaks when folder already ends in /, or on Windows. Use Path(folder) / name.
Forgetting that Path is not a string. Path("a.txt") + ".bak" raises TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'. Use p.with_suffix(...), p.with_name(...), or Path(str(p) + ".bak").
Unsorted listings. Output order from iterdir/glob is not alphabetical. Sort.
Relative paths and the cwd. Path("data.txt") is relative to where the program was started. To find files next to your script, use Path(__file__).parent / "data.txt" (__file__ is the script's own path; the modules module explains it).
mkdir errors. FileExistsError on rerun means you need exist_ok=True; FileNotFoundError means you need parents=True.
Recap
Pathobjects represent locations; creating one does not touch the disk.- Join with
/; take apart with.name,.stem,.suffix,.parent; modify with.with_suffix(),.with_name(). - Query with
exists(),is_file(),is_dir(),stat(); read and write small files withread_text/write_text(with an encoding). - List with
iterdir(),glob(),rglob(), and sort the results. - Create with
mkdir(parents=True, exist_ok=True), move withrename, delete withunlink/rmdir.
Going further: read the pathlib documentation's table "Corresponding tools" that maps os and os.path functions to Path methods, so you can read older code. Try Path("/proc/self").resolve() on Linux to see your own process id.