Topic 7.6
Paths the Modern Way: pathlib
In one line
pathlib.Path represents a file or folder location as an object. You join parts with /, ask questions like exists(), read and write with one call, search with glob, and your code works on Windows, macOS and Linux alike.
Think of it like this
A postal address. "Flat 4, Green Lane, Pune" tells a courier exactly where to go, written in the order house, street, city. A path is the address of a file inside a computer: folder, subfolder, file name. Different countries write addresses differently, and different operating systems write paths differently (Windows data\reports\a.txt, Linux and macOS data/reports/a.txt). pathlib lets you write the address once and handles the local style for you.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Path
- A location of a file or folder, such as data/reports/a.txt.
- Relative path
- A path that starts from the folder the program is running in.
- Absolute path
- A path that starts at the root of the disk, so it means the same thing wherever the program runs.
- Working directory
- The folder a running program treats as 'here'. Relative paths start from it.
- Suffix
- The file extension, including the dot, such as .txt.
- Stem
- The file name without its final suffix.
- Glob
- A pattern with wildcards, such as *.txt, used to find matching files.
- Immutable
- Cannot be changed after it is made. Path methods return new Path objects.
Step by step
01Build a path with /
from pathlib import Path brings the tool in (Topic 6.1). Start from Path("data") and add parts with /. The result is a Path, not a string. Printing it shows the path in the style of your operating system. Nothing is created on disk yet.
from pathlib import Path
base = Path("data")
report = base / "reports" / "summary.final.txt"
print(report) # data/reports/summary.final.txt
print(type(report).__name__) # PosixPath on Linux/macOS, WindowsPath on Windows02Take it apart
Every path has named pieces. suffix is only the last extension, so a file called archive.tar.gz has the suffix .gz and the list of all of them is in .suffixes.
print(report.name) # summary.final.txt
print(report.stem) # summary.final
print(report.suffix) # .txt
print(report.parent) # data/reports
print(report.parts) # ('data', 'reports', 'summary.final.txt')
print(report.with_suffix(".md")) # data/reports/summary.final.md03Create folders and files
mkdir(parents=True, exist_ok=True) is the safe way to say "make sure this folder exists": it creates missing parent folders and does not complain when the folder is already there. Then write_text creates the file in one call.
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text("totals\n", encoding="utf-8")
print(report.exists(), report.is_file(), report.parent.is_dir())
print(report.read_text(encoding="utf-8"))04Find files: iterdir, glob, rglob
iterdir lists one level. glob filters by pattern. rglob goes into every subfolder. The operating system decides the order, so we sort before printing or processing. This continues from the base and report paths of the earlier steps.
for p in sorted(base.iterdir()):
print(p)
print(sorted(base.glob("*.txt")))
print(sorted(base.rglob("*.txt")))05Copy, move and delete with shutil
pathlib can rename and delete single files. shutil handles copying and whole folders. rmtree deletes everything below a folder with no way back, so check the path before you call it.
import shutil
shutil.copy(report, base / "copy.txt")
shutil.move(base / "copy.txt", base / "moved.txt")
shutil.rmtree(base / "reports") # deletes the folder and everything inside06One path, any operating system
PureWindowsPath and PurePosixPath let you see how the same parts look on each system without touching a disk. Your code only has to join with /, and pathlib uses the right separator when you run it.
from pathlib import PureWindowsPath, PurePosixPath
print(PureWindowsPath("data") / "reports" / "a.txt") # data\reports\a.txt
print(PurePosixPath("data") / "reports" / "a.txt") # data/reports/a.txtTry it yourself
- 1
Take apart your own path
In the first example replace the path with
Path("photos/holiday/IMG_001.jpg")and predict each printed line before running. Then tryPath("notes")with no suffix. What is.suffixthen? - 2
Add a file type
In the search example add a file called
blog/data.jsonand printsorted(root.rglob("*.json")). Then try the pattern*.[ph]*and see which names it matches. - 3
Check before deleting
In the last example, before
shutil.rmtree, print(work / "archive").exists(). Then callrmtreetwice in a row and read the error from the second call.
Code & diagrams
Creating a Path does not touch the disk, so these file names do not need to exist.
Expected output
path : data/reports/summary.final.txt
name : summary.final.txt
stem : summary.final
suffix : .txt
parent : data/reports
parts : ('data', 'reports', 'summary.final.txt')
as .md : data/reports/summary.final.md
absolute?: False
archive : ['.tar', '.gz']Expected output
exists before: False
exists after : True True
is file: True
text : learn pathlib
size : 14 bytes
after unlink: FalseRemove the sorted(...) calls: the code still runs, but you can no longer rely on the order. Always sort before printing.
Expected output
top level : ['about.html', 'blog', 'index.html', 'logo.png']
*.html here: ['about.html', 'index.html']
all *.html : ['site/about.html', 'site/blog/post1.html', 'site/blog/post2.html', 'site/index.html']
all *.png : ['site/blog/img/cover.png', 'site/logo.png']Expected output
inbox : []
archive: ['a-copy.txt', 'a-moved.txt']
after rmtree: ['inbox']Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Add a string to a Path with +
Join a path and a string with +.
from pathlib import Path
folder = Path("data")
print(folder + "/a.txt")Break #2
Write into a folder that does not exist
Create a file in a missing folder.
from pathlib import Path
Path("reports/2026").joinpath("jan.txt").write_text("x", encoding="utf-8")Myth vs fact
Myth
Path("a.txt") creates the file.
Fact
Making a Path object changes nothing on disk. It is only a description of a location. Files appear when you write to them or call touch/mkdir.
Myth
glob returns files in alphabetical order.
Fact
The order comes from the operating system and can differ between machines and runs. Sort the results.
Myth
Strings are fine for paths; pathlib is just fashion.
Fact
String paths break across systems (separators), invite joining bugs and carry no helpers. pathlib removes a whole class of bugs and reads better.
Myth
A relative path always means the folder of the script.
Fact
It means the folder the program was started from, which can be anywhere. Use Path(__file__).parent for files that sit next to your code.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Pathobjects are immutable and hashable, so they work as dictionary keys and set members. They also accept being passed toopen(),osfunctions and most libraries (they implementos.PathLike), so you rarely needstr(path). - ▸
Path.resolve()makes a path absolute and removes..and symbolic links;Path.expanduser()turns~into the home folder. Do both when you accept paths from a user or a config file. - ▸
Security: never build a path from user input and trust it. A name like
../../secretcan climb out of your folder (path traversal). Resolve the final path and check that it still sits inside the allowed folder withresolved.is_relative_to(allowed)(3.9), or accept only bare file names. Do not usestr(resolved).startswith(str(allowed)):/srv/uploads-evil/xstarts with/srv/uploadsbut is outside it. Andis_relative_toon an unresolved path only compares the text, so/srv/uploads/../../etc/passwdstill looks inside; callresolve()first. - ▸
The
/operator has a trap: if the right-hand part is absolute, it replaces everything on the left.Path("data") / "/etc/passwd"is/etc/passwd, notdata/etc/passwd. That is the usual way an uploaded file name escapes an upload folder. Also, on Windows paths compare case-insensitively (PureWindowsPath("A.TXT") == PureWindowsPath("a.txt")isTrue), while on Linux they do not. - ▸
Path.walk()(3.12) is the pathlib version ofos.walk; 3.13 addedPath.full_match()(match a whole path against a pattern with**) and arecurse_symlinksoption forglob. For millions of filesos.scandiris faster because it avoids creating an object per entry.Path.renamefails on Windows if the target exists, whilePath.replaceoverwrites silently on every system, which is what you want for the atomic-save recipe in Topic 7.4.
Remember this
- 1
Older code builds paths by gluing strings:
"data" + "/" + "a.txt". That breaks on Windows, breaks when a part already ends with a slash, and gives you plain text with no helpers. Thepathlibmodule (standard library, Python 3.4+) gives you aPathobject instead. (An object is a value that comes with its own helper functions, called methods, which you call asvalue.method().)Path("data") / "reports" / "a.txt"joins parts with the/operator and always produces a correct path for the system you are on. - 2
A
Pathknows its own pieces. TakePath("data/reports/summary.final.txt"). Its.nameissummary.final.txt, its.stemissummary.final, and its.suffixis.txt. Its.parentisdata/reports. Its.partsis the tuple('data', 'reports', 'summary.final.txt')(Topic 4.3). You can make changed copies:.with_suffix(".md")and.with_name("other.txt"). APathnever changes in place (it is immutable), and creating one touches nothing on disk. - 3
To ask the disk questions, use methods:
p.exists(),p.is_file(),p.is_dir(). To change the disk, use these.p.mkdir(parents=True, exist_ok=True)creates a folder, and any missing parent folders, without an error if it already exists.p.unlink()deletes a file.p.rmdir()deletes an empty folder.p.rename(new)moves or renames. For small files,p.read_text(encoding="utf-8")andp.write_text(text, encoding="utf-8")replace the whole open/read/close routine of Topic 7.5 with one call;read_bytesandwrite_bytesdo the same for binary data. - 4
To look inside a folder,
p.iterdir()gives you everything directly in it, andp.glob("*.txt")gives you the matches of a pattern (*matches any characters,?one character). Both hand the items out one at a time, as a generator (Topic 9.2), so wrap them inlist(...)orsorted(...)to see them all.p.rglob("*.py")searches all subfolders as well (the same asglob("**/*.py")). The order of the results is not guaranteed, because the operating system decides it. Wrap them insorted(...)whenever the order matters, which is nearly always. - 5
Relative paths (
data/a.txt) start from the folder your program is running in (the current working directory). Absolute paths (/home/amit/data/a.txtorC:\Users\Amit\a.txt) start at the root of the disk.Path.cwd()gives the current folder andPath.home()the user's home folder.p.resolve()turns a path into an absolute one.Path(__file__).parentis the folder that holds the current script (__file__is a name Python sets to the script's own path). It is the reliable way to find files that sit next to your code. - 6
The
shutilmodule ("shell utilities") handles operationspathlibleaves out.shutil.copy(src, dst)copies a file.shutil.copytree(src, dst)copies a folder.shutil.move(src, dst)moves across disks.shutil.rmtree(folder)deletes a folder with everything in it. Be careful with that last one: there is no recycle bin. In AI projectspathlibis the standard way to locate datasets, model files and output folders without hard-coding a machine's layout.
Explain it without notes
Why use pathlib.Path instead of joining strings with + or os.path.join?
What is the difference between a relative and an absolute path, and how do you find the folder that holds the current script?
Why must you sort the results of glob and iterdir?
What do mkdir(parents=True, exist_ok=True) and shutil.rmtree do, and what are their dangers?
Practice
Given p = Path("photos/trip/beach.jpeg"), print its name, stem, suffix and the name of its parent folder on one line, separated by spaces. Hint: the parent is itself a Path, so it has a .name too.
Create the folder out/logs (with mkdir). Write the text ok into out/logs/a.log and out/logs/b.log. Then print a list of the names of all .log files under out, in sorted order. Hint: rglob("*.log") searches every subfolder.
Write file_size(path) that returns the size in bytes of a file, or -1 if it does not exist. Do not use try/except; check first with is_file(). Hint: p.stat().st_size is the size.
Trade-offs
- ↔
**
pathlibversusos.path.**pathlibis more readable and object-based.os.pathandosstill appear in older code and some low-level tasks (environment, process handling), and both work together because they accept the same paths. - ↔
**
globversusos.scandir.**globis short and expressive. For huge directories,scandir(oros.walk) is faster and lets you decide per entry, at the cost of more code.
Done when you can
Done when you can build a path with
/and take it apart withname,stem,suffixandparent.Done when you can create folders safely with
mkdir(parents=True, exist_ok=True).Done when you can read and write small files with
read_textandwrite_text.Done when you can list or search files with
iterdir,globandrglob, and sort the results.Done when you can explain relative versus absolute paths and find the script's own folder.