Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~27 min· topic 6 of 12

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.

main.pywhole filepython
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 Windows

02Take 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.

main.pywhole filepython
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.md

03Create 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.

main.pywhole filepython
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.

main.pywhole filepython
for p in sorted(base.iterdir()):
    print(p)
print(sorted(base.glob("*.txt")))
print(sorted(base.rglob("*.txt")))
Find files: iterdir, glob, rglobdiagram
Rendering diagram…

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.

main.pywhole filepython
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 inside

06One 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.

main.pywhole filepython
from pathlib import PureWindowsPath, PurePosixPath

print(PureWindowsPath("data") / "reports" / "a.txt")   # data\reports\a.txt
print(PurePosixPath("data") / "reports" / "a.txt")     # data/reports/a.txt

Try it yourself

  1. 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 try Path("notes") with no suffix. What is .suffix then?

  2. 2

    Add a file type

    In the search example add a file called blog/data.json and print sorted(root.rglob("*.json")). Then try the pattern *.[ph]* and see which names it matches.

  3. 3

    Check before deleting

    In the last example, before shutil.rmtree, print (work / "archive").exists(). Then call rmtree twice in a row and read the error from the second call.

Code & diagrams

Pieces of a path New tab

Creating a Path does not touch the disk, so these file names do not need to exist.

Sign in to run this example in your browser.

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']
Create, check, read and write New tab
Sign in to run this example in your browser.

Expected output

exists before: False
exists after : True True
is file: True
text   : learn pathlib
size   : 14 bytes
after unlink: False
Search a folder tree (sorted) New tab

Remove the sorted(...) calls: the code still runs, but you can no longer rely on the order. Always sort before printing.

Sign in to run this example in your browser.

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']
Copy, move and remove with shutil New tab
Sign in to run this example in your browser.

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 +.

main.pywhole filepython
from pathlib import Path

folder = Path("data")
print(folder + "/a.txt")
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 4, in <module>
print(folder + "/a.txt")
~~~~~~~^~~~~~~~~~
TypeError: unsupported operand type(s) for +: 'PosixPath' and 'str'

Break #2

Write into a folder that does not exist

Create a file in a missing folder.

main.pywhole filepython
from pathlib import Path

Path("reports/2026").joinpath("jan.txt").write_text("x", encoding="utf-8")
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 3, in <module>
Path("reports/2026").joinpath("jan.txt").write_text("x", encoding="utf-8")
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/lib/python313.zip/pathlib/_local.py", line 555, in write_text
return PathBase.write_text(self, data, encoding, errors, newline)
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/lib/python313.zip/pathlib/_abc.py", line 651, in write_text
with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/lib/python313.zip/pathlib/_local.py", line 537, in open
return io.open(self, mode, buffering, encoding, errors, newline)
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 44] No such file or directory: 'reports/2026/jan.txt'

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.

  • ▸

    Path objects are immutable and hashable, so they work as dictionary keys and set members. They also accept being passed to open(), os functions and most libraries (they implement os.PathLike), so you rarely need str(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 ../../secret can climb out of your folder (path traversal). Resolve the final path and check that it still sits inside the allowed folder with resolved.is_relative_to(allowed) (3.9), or accept only bare file names. Do not use str(resolved).startswith(str(allowed)): /srv/uploads-evil/x starts with /srv/uploads but is outside it. And is_relative_to on an unresolved path only compares the text, so /srv/uploads/../../etc/passwd still looks inside; call resolve() 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, not data/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") is True), while on Linux they do not.

  • ▸

    Path.walk() (3.12) is the pathlib version of os.walk; 3.13 added Path.full_match() (match a whole path against a pattern with **) and a recurse_symlinks option for glob. For millions of files os.scandir is faster because it avoids creating an object per entry. Path.rename fails on Windows if the target exists, while Path.replace overwrites silently on every system, which is what you want for the atomic-save recipe in Topic 7.4.

Remember this

  1. 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. The pathlib module (standard library, Python 3.4+) gives you a Path object instead. (An object is a value that comes with its own helper functions, called methods, which you call as value.method().) Path("data") / "reports" / "a.txt" joins parts with the / operator and always produces a correct path for the system you are on.

  2. 2

    A Path knows its own pieces. Take Path("data/reports/summary.final.txt"). Its .name is summary.final.txt, its .stem is summary.final, and its .suffix is .txt. Its .parent is data/reports. Its .parts is the tuple ('data', 'reports', 'summary.final.txt') (Topic 4.3). You can make changed copies: .with_suffix(".md") and .with_name("other.txt"). A Path never changes in place (it is immutable), and creating one touches nothing on disk.

  3. 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") and p.write_text(text, encoding="utf-8") replace the whole open/read/close routine of Topic 7.5 with one call; read_bytes and write_bytes do the same for binary data.

  4. 4

    To look inside a folder, p.iterdir() gives you everything directly in it, and p.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 in list(...) or sorted(...) to see them all. p.rglob("*.py") searches all subfolders as well (the same as glob("**/*.py")). The order of the results is not guaranteed, because the operating system decides it. Wrap them in sorted(...) whenever the order matters, which is nearly always.

  5. 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.txt or C:\Users\Amit\a.txt) start at the root of the disk. Path.cwd() gives the current folder and Path.home() the user's home folder. p.resolve() turns a path into an absolute one. Path(__file__).parent is 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. 6

    The shutil module ("shell utilities") handles operations pathlib leaves 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 projects pathlib is the standard way to locate datasets, model files and output folders without hard-coding a machine's layout.

Explain it without notes

01

Why use pathlib.Path instead of joining strings with + or os.path.join?

02

What is the difference between a relative and an absolute path, and how do you find the folder that holds the current script?

03

Why must you sort the results of glob and iterdir?

04

What do mkdir(parents=True, exist_ok=True) and shutil.rmtree do, and what are their dangers?

Practice

01

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.

02

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.

03

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

  • ↔

    **pathlib versus os.path.** pathlib is more readable and object-based. os.path and os still appear in older code and some low-level tasks (environment, process handling), and both work together because they accept the same paths.

  • ↔

    **glob versus os.scandir.** glob is short and expressive. For huge directories, scandir (or os.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 with name, stem, suffix and parent.

  • 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_text and write_text.

  • Done when you can list or search files with iterdir, glob and rglob, and sort the results.

  • Done when you can explain relative versus absolute paths and find the script's own folder.