Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 8 – Files, paths and data formats

pathlib: paths, folders and file operations

Build and split paths with pathlib.Path, list and search folders with glob and walk, copy, move and delete files, and refuse paths that escape a folder.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build and take apart paths with pathlib.Path
  • Predict where a relative path points from the current working directory
  • List, search and walk folders, and sort what they return
  • Copy, move and delete files safely, with a dry run first
  • Validate paths from outside, and use TemporaryDirectory for scratch work

Before you start

What you’ll learn · Before you start

What you will learn

  • Build and take apart paths with pathlib.Path
  • Predict where a relative path points from the current working directory
  • List, search and walk folders, and sort what they return
  • Copy, move and delete files safely, with a dry run first
  • Validate paths from outside, and use TemporaryDirectory for scratch work

Before you start

On this page

A script that opens data/marks.csv works when you start it from its own folder and fails from any other, because a relative path: A path that does not start at a root, such as data/marks.csv. Python reads it from the current working directory each time it is used, not from the folder of the script that holds it. follows the current folder. A name such as ../../etc/passwd, joined to a folder you trust, climbs two levels out of it, and one call to shutil.rmtree() deletes a whole folder tree without asking.

pathlib is the standard library’s module for file system paths. A pathlib.Path stands for a path such as data/raw/marks.csv: you build one with the / operator, take it apart with .name, .suffix and .parent, and call its methods to list folders (iterdir(), glob(), walk()), create them (mkdir()), read and write files (read_text(), write_text()), and copy, move or delete them (copy(), move(), unlink()). The same code runs on Windows, macOS and Linux. Below you will watch a relative path change its meaning, walk a folder tree, plan file moves before you make them, and refuse names that leave their folder.

A Path is built from parts and taken apart by its attributes

Path("data") makes a path object; on Linux and macOS it is a PosixPath and on Windows a WindowsPath, which differ only in how they write and compare paths. The / operator joins parts, and attributes give back the pieces:

One path, taken apart Python · path_parts.py
from pathlib import Path, PureWindowsPath

report = Path("data") / "term-1" / "marks.final.csv"  # / joins the parts
print(report)
print("name:     ", report.name)
print("stem:     ", report.stem)
print("suffix:   ", report.suffix)
print("suffixes: ", report.suffixes)
print("parent:   ", report.parent)
print("parents:  ", [str(p) for p in report.parents])
print("parts:    ", report.parts)

# The with_ methods return a new path; a Path never changes.
print(report.with_suffix(".json"))
print(report.with_name("summary.txt"))
print(report.with_stem("marks"))

# Windows paths can be handled on any computer with PureWindowsPath.
win = PureWindowsPath(r"C:\Users\asha\Documents\marks.csv")
print(win.drive, win.name, win.parent)
print(win.as_posix())

Output

data/term-1/marks.final.csv
name:      marks.final.csv
stem:      marks.final
suffix:    .csv
suffixes:  ['.final', '.csv']
parent:    data/term-1
parents:   ['data/term-1', 'data', '.']
parts:     ('data', 'term-1', 'marks.final.csv')
data/term-1/marks.final.json
data/term-1/summary.txt
data/term-1/marks.csv
C: marks.csv C:\Users\asha\Documents
C:/Users/asha/Documents/marks.csv

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 path_parts.py

The table names each piece of the path the example built, data/term-1/marks.final.csv:

Attribute Its value here What it is
name marks.final.csv the last part
suffix .csv the name from its last dot
stem marks.final the name without that suffix
suffixes ['.final', '.csv'] every dotted ending, as in backup.tar.gz
parent data/term-1 the path without its last part
parts ('data', 'term-1', 'marks.final.csv') every part, in order

A name with two dots keeps one of them in its stem, and parents lists every folder above the path. A Path is immutable: Unable to change once made. Strings, numbers and tuples are immutable: an operation that seems to change one, such as upper(), returns a new object instead. More in “Strings: literals, indexing and slicing”: with_suffix(), with_name() and with_stem() return a new path. Most functions that take a file name, open() included, accept a Path directly, and str(path) gives the text when something needs a string.

PureWindowsPath and PurePosixPath work with paths of either system on any computer, without touching any file system. That helps when a program reads Windows paths from a file on a Linux server. as_posix() writes any path with forward slashes, which is how this lesson’s examples print paths so that every system shows the same text.

Joining paths with + or f-strings

folder + "/" + name gives data//marks.csv when folder is "data/", and it puts / into Windows paths, which Windows writes with \. Join Path objects with / instead: Path(folder) / name gives data/marks.csv, written data\marks.csv on Windows.

Checkpoint

Check: Building and splitting paths

  1. Question 1 of 2 For p = Path("backups/site.tar.gz"), what are p.stem and p.suffix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "site.tar" and ".gz"

    suffix is the part of the name from its last dot, ".gz", and stem is the name without that suffix, "site.tar". p.suffixes gives every ending, [".tar", ".gz"], which helps with names such as backup.tar.gz.

  2. Question 2 of 2 What does this print?

    Read the code, then choose one answer.

    from pathlib import Path
    
    report = Path("data") / "march.csv"
    report.with_suffix(".json")
    print(report.as_posix())
    Show the answer: Question 2 of 2

    Answer: data/march.csv

    Paths are immutable: the with_ methods return a new Path and leave the old one as it was. Write report = report.with_suffix(".json") to keep the new path.

A relative path starts from the current working directory

A path that does not start at a root, such as data/marks.csv, is a relative path: it is resolved from the current working directory: The folder that a program's relative paths start from: by default the folder the program was started from. Path.cwd() returns it, and os.chdir() changes it for the whole program., the folder the program was started from, and not from the folder that holds the script. Step through one relative path before and after the current folder changes:

Interactive · step by step The same relative path, before and after os.chdir() Step through relative.py. The sub-labels say which folder is current and whether marks.exists() finds the file.
The same relative path, before and after os.chdir(), step 5 of 5. Showing The starting folder (no longer current), data (current folder now), marks.csv (exists() is True), data/data, data/data/marks.csv (exists() is False). In focus: marks.csv.The starting folderno longer currentdatacurrent folder nowmarks.csvexists() is Truedata/datano such folderdata/data/marks.csvexists() is False

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A relative path. Path("data") / "marks.csv" does not start at a root, so is_absolute() is False. It names data/marks.csv inside whichever folder is current when it is used.
  2. Make the file. mkdir() creates data in the current folder, and write_text() creates marks.csv inside it. marks.exists() is True.
  3. Change folder. os.chdir("data") makes data the current working directory. The Path object has not changed; it still says data/marks.csv.
  4. Same path, new place. Read from the new current folder, data/marks.csv means data/data/marks.csv. No such file exists, so marks.exists() is now False.
  5. The file is still there. marks.csv has not moved. Only the meaning of the relative path changed, which is why a script that relies on the current folder breaks when it is started from somewhere else.

The program behind the figure, to run:

The same relative path, two current folders Python · relative.py
import os
from pathlib import Path

marks = Path("data") / "marks.csv"
print("absolute?", marks.is_absolute())

start = Path.cwd()  # the folder the program was started from
Path("data").mkdir()
marks.write_text("name,score\n", encoding="utf-8")
print("exists?", marks.exists())

os.chdir("data")  # change the current working directory
print("now in:", Path.cwd().relative_to(start))
print("exists?", marks.exists())  # the same relative path now means data/data/marks.csv
print("points to:", marks.absolute().relative_to(start))

Output

absolute? False
exists? True
now in: data
exists? False
points to: data/data/marks.csv

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 relative.py

After os.chdir(), the same Path object pointed one folder deeper and no longer found the file. A script that opens Path("data/marks.csv") therefore works when you run it from its own folder and fails when a scheduler or a colleague starts it from somewhere else. For files that ship with your code, build the path from the script’s own location, Path(__file__).resolve().parent / "data" / "marks.csv". For the user’s files, take the path as an argument. Path.cwd() and Path.home() give the current folder and the user’s home folder, and resolve() turns a path into an absolute one.

Key idea

A relative path is read from the current folder each time it is used: build paths to your own files from Path(__file__), and take the user’s paths as arguments.

Checkpoint

Check: Where a relative path points

  1. Question 1 of 2 marks = Path("data") / "marks.csv" exists in the current folder. The program then runs os.chdir("data"). What does marks.exists() return now, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: False, because the same relative path now means data/data/marks.csv

    os.chdir() changes the current working directory for the whole program. A relative path is resolved from there when it is used, so after the change data/marks.csv names a file one folder deeper, which does not exist.

  2. Question 2 of 2 A script reads data/settings.json, a file that ships next to it. It works when you run it from its own folder but fails when a scheduler starts it from your home folder. Which fix makes it work from anywhere?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Build the path as Path(__file__).resolve().parent / "data" / "settings.json"

    For files that ship with your code, start from the script's own location, Path(__file__).resolve().parent. For files the user chooses, take the path as an argument instead.

iterdir() lists one folder, glob() and walk() search a tree

Three methods look inside a folder, and walk() goes through a whole tree. This example builds a small project and asks each of them what is in it:

iterdir, glob, rglob, Path.info and walk Python · listing.py
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    project = Path(tmp) / "project"
    for name in ["main.py", "README.md", "src/app.py", "src/util.py", "src/data/marks.csv", "tests/test_app.py"]:
        file = project / name
        file.parent.mkdir(parents=True, exist_ok=True)  # create missing folders on the way
        file.write_text(f"# {name}\n", encoding="utf-8")

    def show(paths):
        """Paths relative to the project, sorted: iterdir() and glob() promise no order."""
        return sorted(p.relative_to(project).as_posix() for p in paths)

    print("iterdir:", show(project.iterdir()))  # one level only
    print("glob *.py:", show(project.glob("*.py")))  # matches in this folder
    print("rglob *.py:", show(project.rglob("*.py")))  # matches at any depth
    print("glob **/*.csv:", show(project.glob("**/*.csv")))

    # Path.info (Python 3.14) caches what iterdir() learnt about each entry.
    for entry in sorted(project.iterdir()):
        kind = "folder" if entry.info.is_dir() else "file"
        print(f"  {entry.name:<10} {kind}")

    # walk() (Python 3.12) visits every folder once, with its subfolders and files.
    for folder, subfolders, files in project.walk():
        subfolders.sort()  # walk() visits subfolders in the order of this list
        print(folder.relative_to(project).as_posix(), sorted(files))

Output

iterdir: ['README.md', 'main.py', 'src', 'tests']
glob *.py: ['main.py']
rglob *.py: ['main.py', 'src/app.py', 'src/util.py', 'tests/test_app.py']
glob **/*.csv: ['src/data/marks.csv']
  README.md  file
  main.py    file
  src        folder
  tests      folder
. ['README.md', 'main.py']
src ['app.py', 'util.py']
src/data ['marks.csv']
tests ['test_app.py']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 listing.py

Press Edit, add "docs/guide.md" to the list of names and run it again: iterdir() and walk() find a new folder, while rglob("*.py") finds the same four files.

The table sums up what each call found in that project:

Call What it found How far it looks
iterdir() README.md, main.py, src, tests the folder itself, files and folders alike
glob("*.py") main.py names in the folder that match the pattern
rglob("*.py") main.py, src/app.py, src/util.py, tests/test_app.py the pattern, at any depth
glob("**/*.csv") src/data/marks.csv ** stands for any number of folders
walk() a triple for each of ., src, src/data and tests every folder of the tree, once

glob(pattern) takes a glob pattern: A pattern for file names from the shell: * matches any run of characters within one name, ? one character, and in pathlib ** any number of folders, so **/*.csv finds CSV files at any depth.: * matches any run of characters within one name, ? one character and ** any number of folders, so glob("**/*.csv") searches the whole tree. rglob(pattern) is glob() with **/ put in front of the pattern: rglob("*.py") finds Python files at any depth. walk() (Python 3.12 and later) visits each folder once and gives its path, the names of its subfolders and the names of its files. Step through the four folders the example walked:

Interactive · step by step walk() visits each folder of the project once Step through the walk of listing.py. Each step is one triple, a folder with the names of its files; done folders are ticked.
walk() visits each folder of the project once, step 5 of 5. Showing project (subfolders: src, tests), README.md, main.py, src (subfolders: data), app.py, util.py, src/data, marks.csv, tests, test_app.py. In focus: tests, test_app.py.projectsubfolders: src, testsREADME.md, main.pysrcsubfolders: dataapp.py, util.pysrc/datamarks.csvteststest_app.py

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The folder itself first. walk() starts with the folder it is called on. Its first triple is the project folder, printed as ".", with its two files and its subfolders src and tests.
  2. Sorted on the spot. The loop sorts the subfolder list in place, and walk() goes into the folders in the order the list then has, so src comes before tests on every computer.
  3. Into src. Top-down, a folder comes before the folders inside it. src holds app.py and util.py, and one subfolder, data.
  4. One level deeper. Then src/data, with marks.csv and no subfolders, so that branch is finished.
  5. The last folder. Last comes tests, with test_app.py. Four folders gave four triples, and walk() visited each of them once.

Removing a name from the subfolder list stops walk() from going into that folder, which is how a search skips folders such as .git or node_modules; change the list in place, as the example’s subfolders.sort() does. None of these methods promise an order, so the example sorts every result before printing it; without sorting, the same program can print a different order on another computer. Each entry that iterdir() returns also carries Path.info, new in Python 3.14, which already knows from the folder listing whether the entry is a file or a folder and caches the answer. The cache is never refreshed, so call is_dir() or is_file() on the path itself when the folder may have changed since.

Expecting results in alphabetical order

iterdir(), glob() and walk() follow the file system, and their order can differ from one computer to the next. Sort what they return whenever the order matters: sorted(folder.iterdir()).

The five largest files under a folder

rglob("*") and stat() are enough for a disk-space report. heapq.nlargest() keeps only the five biggest files it has seen so far, so it does not sort a list of every file:

The five largest files, at any depth Python · largest_files.py
import heapq
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    downloads = Path(tmp) / "downloads"
    sizes = {"setup.exe": 48_000, "photos/beach.jpg": 3_200, "photos/hill.jpg": 2_900, "notes.txt": 120,
             "video/clip.mp4": 61_000, "video/old/trailer.mp4": 39_500, "music/song.mp3": 5_400}
    for name, size in sizes.items():
        file = downloads / name
        file.parent.mkdir(parents=True, exist_ok=True)
        file.write_bytes(b"\0" * size)  # a file of exactly `size` bytes

    files = (p for p in downloads.rglob("*") if p.is_file())
    for file in heapq.nlargest(5, files, key=lambda p: p.stat().st_size):
        print(f"{file.stat().st_size:>7,} bytes  {file.relative_to(downloads).as_posix()}")

Output

 61,000 bytes  video/clip.mp4
 48,000 bytes  setup.exe
 39,500 bytes  video/old/trailer.mp4
  5,400 bytes  music/song.mp3
  3,200 bytes  photos/beach.jpg

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 largest_files.py

stat() asks the operating system for a file’s details: st_size is its size in bytes and st_mtime the time it was last changed. The is_file() filter leaves out folders, whose size would mean something else.

Folder Tree Generator Paste the paths that rglob() prints to draw them as a folder tree, for example in a README.

Checkpoint

Check: Listing and searching folders

  1. Question 1 of 2 Which calls find the .py files of a folder and of all of its subfolders?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • folder.glob("**/*.py")
    • folder.rglob("*.py")

    rglob() puts "**/" in front of its pattern, so the first two are the same search. glob("*.py") and iterdir() look only at the folder itself.

  2. Question 2 of 2 In the walk of the project folder, why does the loop call subfolders.sort() before printing?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: walk() goes into the subfolders in the order of that list, and the order it gets from the file system is not fixed

    Whether walk(), iterdir() and glob() return names in any order depends on the file system. Changing the list of subfolders in place, top-down, changes the order walk() visits them in, or prunes the walk when a name is removed.

Copying and moving overwrite, deleting cannot be undone

mkdir() creates a folder. With parents=True it also creates every missing folder above it, and exist_ok=True makes it quiet when the folder is already there; the listing example uses both. write_text() and read_text() open, write or read, and close a file in one call, and they take the same encoding argument as open(). Python 3.14 added copying and moving to Path:

copy, copy_into, unlink, rmdir and rmtree Python · copy_delete.py
import shutil
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp)
    site = root / "site"
    (site / "css").mkdir(parents=True)
    (site / "index.html").write_text("<h1>Hi</h1>\n", encoding="utf-8")
    (site / "css" / "main.css").write_text("h1 { color: teal }\n", encoding="utf-8")

    # Python 3.14: copy() copies a file or a whole folder tree and returns the new path.
    backup = site.copy(root / "site-backup")
    print("copied:", sorted(p.relative_to(root).as_posix() for p in backup.rglob("*")))
    (site / "index.html").copy_into(root)  # copy_into() keeps the name
    print("top level:", sorted(p.name for p in root.iterdir()))

    # Deleting: unlink() for files, rmdir() for empty folders, shutil.rmtree() for trees.
    (root / "index.html").unlink()
    (root / "index.html").unlink(missing_ok=True)  # already gone: no error
    try:
        backup.rmdir()
    except OSError as err:
        print("rmdir refused:", type(err).__name__)
    shutil.rmtree(backup)  # no confirmation and no undo
    print("left:", sorted(p.name for p in root.iterdir()))

Output

copied: ['site-backup/css', 'site-backup/css/main.css', 'site-backup/index.html']
top level: ['index.html', 'site', 'site-backup']
rmdir refused: OSError
left: ['site']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copy_delete.py

The example used five of the calls in this table. None of them asks before it acts:

Call What it does A file already at the target
copy(target) copies a file or a whole folder tree and returns the new path is replaced
copy_into(folder) copies into a folder and keeps the name is replaced
move(target) moves a file or a tree, with os.replace() on one file system is replaced
move_into(folder) moves into a folder and keeps the name is replaced
unlink() deletes a file; missing_ok=True is quiet when it is already gone no target
rmdir() deletes a folder, but only an empty one no target
shutil.rmtree(folder) deletes a folder and everything in it no target

Deleting a folder you did not check

shutil.rmtree() deletes whatever its path names, with no confirmation, and nothing goes to a recycle bin. An empty or wrong value can name a parent folder: check that the path is the folder you mean, and print the plan before you delete anything.

The example shows rmdir() refusing a folder that still held files, and shutil.rmtree() deleting the same folder with everything in it a line later.

Version note

Path.copy(), copy_into(), move(), move_into() and Path.info are new in Python 3.14. On older versions use shutil.copy2() for a file, shutil.copytree() for a folder tree and shutil.move() to move either.

Organising a downloads folder, with a dry run first

A program that moves or deletes many files should first work out what it would do, show the plan, and only then do it: a dry run: A run that only reports what a program would do, such as the list of files it would move, and changes nothing. The real run then carries out the same plan.. Here every file of a downloads folder goes into a subfolder chosen by its extension:

Plan the moves, show them, then make them Python · organise.py
import tempfile
from pathlib import Path

RULES = {".pdf": "documents", ".docx": "documents", ".jpg": "images", ".png": "images", ".zip": "archives"}


def plan(folder):
    """The moves organising would make, as (file, subfolder) pairs, and the files it must skip."""
    moves, skipped = [], []
    for file in sorted(folder.iterdir()):
        sub = RULES.get(file.suffix.lower())  # ".PDF" and ".pdf" are the same kind of file
        if not file.is_file() or sub is None:
            continue
        if (folder / sub / file.name).exists():
            skipped.append(f"{file.name} ({sub}/{file.name} exists)")  # never overwrite
        else:
            moves.append((file, sub))
    return moves, skipped


with tempfile.TemporaryDirectory() as tmp:
    downloads = Path(tmp) / "downloads"
    (downloads / "images").mkdir(parents=True)
    for name in ["Report.PDF", "cv.docx", "beach.jpg", "logo.png", "backup.zip", "notes.txt", "images/logo.png"]:
        (downloads / name).write_text("x", encoding="utf-8")

    moves, skipped = plan(downloads)
    print("dry run, nothing moved yet:")
    for file, sub in moves:
        print(f"  {file.name} -> {sub}/")
    print("  skipped:", skipped)

    for file, sub in moves:
        target = downloads / sub
        target.mkdir(exist_ok=True)
        file.move_into(target)  # Python 3.14; shutil.move(file, target) on older versions

    print("after:", sorted(p.relative_to(downloads).as_posix() for p in downloads.rglob("*") if p.is_file()))

Output

dry run, nothing moved yet:
  Report.PDF -> documents/
  backup.zip -> archives/
  beach.jpg -> images/
  cv.docx -> documents/
  skipped: ['logo.png (images/logo.png exists)']
after: ['archives/backup.zip', 'documents/Report.PDF', 'documents/cv.docx', 'images/beach.jpg', 'images/logo.png', 'logo.png', 'notes.txt']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 organise.py

plan() changes nothing: it returns the moves and the files it must skip, so the same function serves the dry run and the real one. Three decisions make it safe. The extension is compared in lower case, so Report.PDF counts as a PDF. A file is skipped when its name already exists in the target folder, because move_into() would overwrite images/logo.png without a word. And files with no rule, such as notes.txt, stay where they are.

Matching extensions with name.endswith(".pdf")

endswith(".pdf") misses Report.PDF. Compare path.suffix.lower() with the lower-case extension, as plan() does.

Checkpoint

Check: Copying, moving and deleting

  1. Question 1 of 2 downloads/images/logo.png exists. What does Path("downloads/logo.png").move_into(Path("downloads/images")) do on the same file system?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It replaces images/logo.png with the moved file, without a question

    Path.move() and move_into() overwrite an existing file at the target, and so do copy() and copy_into(). A program that must keep old files checks whether the target exists before it moves anything, as plan() does.

  2. Question 2 of 2 Why does organise.py work out every move in plan() and print it before it moves a single file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The same plan serves the dry run and the real run, so you see exactly what will happen before anything changes

    A dry run shows the plan without changing anything; the real run then carries out the same plan. For programs that move or delete many files, that one printed list is the cheapest safety check there is.

A name from outside can lead out of the folder

A program that serves reports/<name> to whoever asks, or extracts files from an archive, joins a name it did not choose to a folder it trusts. That is a path traversal: A weakness in which a name from outside, such as ../../etc/passwd, makes a program read or write a file outside the folder it meant to use. Resolving the joined path and checking that it is still inside the folder prevents it. risk, and the plain / operator does nothing about it:

Four ways out of a folder, and safe_join Python · safe_join.py
import tempfile
from pathlib import Path


def safe_join(base, user_path):
    """The real location of user_path inside base, or ValueError if it is anywhere else."""
    base = Path(base).resolve()
    target = (base / user_path).resolve()  # follows symbolic links and removes ".."
    if target == base or not target.is_relative_to(base):
        raise ValueError(f"{user_path!r} is not a file inside the folder")
    return target


with tempfile.TemporaryDirectory() as tmp:
    base = Path(tmp).resolve() / "reports"
    (base / "q1").mkdir(parents=True)
    (base / "q1" / "march.pdf").write_text("report", encoding="utf-8")
    (Path(tmp) / "private").mkdir()
    (base / "shortcut").symlink_to(Path(tmp) / "private")  # a link inside base that points out of it

    names = ["q1/march.pdf", "../../etc/passwd", "/etc/passwd", "q1/../../private/key", "shortcut/key"]
    print("Plain join:")
    for name in names:
        joined = base / name
        shown = joined.as_posix() if joined.is_absolute() and not joined.is_relative_to(base) else (
            "reports/" + joined.relative_to(base).as_posix())
        print(f"  {name!r:24} {shown:30} looks inside: {joined.is_relative_to(base)}")
    print("safe_join:")
    for name in names:
        try:
            print(f"  {name!r:24} ok: {safe_join(base, name).relative_to(base).as_posix()}")
        except ValueError as err:
            print(f"  {name!r:24} refused: {err}")

Output

Plain join:
  'q1/march.pdf'           reports/q1/march.pdf           looks inside: True
  '../../etc/passwd'       reports/../../etc/passwd       looks inside: True
  '/etc/passwd'            /etc/passwd                    looks inside: False
  'q1/../../private/key'   reports/q1/../../private/key   looks inside: True
  'shortcut/key'           reports/shortcut/key           looks inside: True
safe_join:
  'q1/march.pdf'           ok: q1/march.pdf
  '../../etc/passwd'       refused: '../../etc/passwd' is not a file inside the folder
  '/etc/passwd'            refused: '/etc/passwd' is not a file inside the folder
  'q1/../../private/key'   refused: 'q1/../../private/key' is not a file inside the folder
  'shortcut/key'           refused: 'shortcut/key' is not a file inside the folder

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 safe_join.py

The plain join failed in three ways. .. climbed out of the folder while the joined path still looked as if it started with reports/. An absolute name, /etc/passwd, replaced the base folder completely, because joining with an absolute path throws away everything before it. And shortcut/key looked harmless, but shortcut is a symbolic link: A file that only points to another path. Opening it opens what it points to, so a link inside a folder can lead anywhere on the computer; Path.resolve() follows links to the real location. that leads out of the folder. is_relative_to() was fooled by the first and the last: the pathlib documentation says it compares the parts of the paths as text and does not treat .. specially.

safe_join() asks the file system where the joined path really leads. resolve() removes .. and follows symbolic links, and only then does is_relative_to() compare the result with the resolved base folder. It also refuses the base folder itself, which names no file inside it. Use it, or the same three lines, wherever a name comes from outside: web requests, uploaded file names, archive members and configuration files. The diagram puts the steps in their order:

Checking a path from outside: join it to the base folder, resolve it, then use it only if it is still inside the base folder.A name from outside:a request, a form,an archive, a config filebase / namean absolute namereplaces baseresolve()removes .. and followssymbolic linksInside base,and not baseitself?Use the pathRaise ValueErroryesno

How safe_join decides whether a name may be used

Text description of the diagram

The diagram is a flow from top to bottom.

  1. A name arrives from outside the program: a web request, a form, a member of an archive or a line of a configuration file.
  2. It is joined to the base folder with the / operator. If the name is an absolute path, it replaces the base folder completely.
  3. resolve() turns the result into the real location: it removes ".." parts and follows symbolic links.
  4. The decision: is the resolved path inside the resolved base folder, and not the base folder itself? If yes, the program may use the path. If no, safe_join raises ValueError and the name is refused.

When you know what to expect, check that as well: an allowlist of extensions such as {".pdf", ".csv"}, or a pattern such as letters, digits and hyphens only, refuses many bad names before any path is built. The OWASP page on path traversal, in the references, also lists percent-encoded and double-encoded forms of ../ and ..\, so check a name after the last decoding your program applies to it, and never decode it again after the check.

Key idea

Resolve first, then compare: only resolve() finds out where .., an absolute name or a link really leads, so only a check on its result keeps a name inside its folder.

Checkpoint

Check: Refusing paths from outside

  1. Question 1 of 2 What is Path("/srv/reports") / "/etc/passwd"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: /etc/passwd

    When a part being joined is an absolute path, everything before it is thrown away, so the result is /etc/passwd. That is one reason a name from outside must be checked after joining.

  2. Question 2 of 2 base = Path("/srv/reports"). What does (base / "../../etc/passwd").is_relative_to(base) return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: True, because is_relative_to() compares the parts as text and does not treat ".." specially

    The joined path still starts with the parts of base, and is_relative_to() looks only at those parts. Call resolve() first: it removes ".." and follows links, and only then is the comparison meaningful.

TemporaryDirectory gives a script a folder that cleans itself up

Every example of this lesson works inside tempfile.TemporaryDirectory(). Used in a with statement, it creates a new, empty folder with a name nobody else uses, in the system’s folder for temporary files, and deletes the folder and everything in it when the block ends, also when the block fails:

A scratch folder that a failed block still removes Python · scratch.py
import tempfile
from pathlib import Path

try:
    with tempfile.TemporaryDirectory() as tmp:
        scratch = Path(tmp)  # a new, empty folder that nobody else uses
        (scratch / "draft.txt").write_text("half done", encoding="utf-8")
        print("inside the block:", [p.name for p in scratch.iterdir()])
        raise RuntimeError("the job failed")
except RuntimeError as err:
    print("caught:", err)

# The folder and everything in it were deleted when the block ended, although it failed.
print("folder still there?", scratch.exists())

Output

inside the block: ['draft.txt']
caught: the job failed
folder still there? False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scratch.py

The block wrote a draft and then failed, yet on the next line the folder was gone. Each call picks a fresh name, so two runs of the same test at the same moment never see each other’s files. That makes it the right home for tests that create files and for scripts that need a scratch area. tempfile.mkdtemp() creates such a folder without deleting it later, when the result must outlive the block.

Checkpoint

Check: Scratch folders with tempfile

  1. One question A test writes files in with tempfile.TemporaryDirectory() as tmp:, and an assertion in the block fails. What happens to the folder?

    Choose one answer.

    Show the answer: One question

    Answer: It is deleted with everything in it as the exception leaves the block

    TemporaryDirectory creates a new, empty folder for the block and deletes the folder and everything in it when the block ends, also when the block fails, which makes it the right home for tests and scratch work.

Practise: refuse unsafe paths, organise a folder

The exercise asks for two functions. safe_join() must refuse every way out of a folder, .., an absolute name, a link that leads out and the folder itself, while accepting names inside it that do not exist yet. organise() must plan moves by extension, never overwrite a file, and change nothing in a dry run. Its sample tests run in your browser:

Exercise · Medium · Python

Refuse unsafe paths and organise a folder

Write two functions in safe_paths.py.

  • safe_join(base, user_path) returns the resolved Path that user_path names inside the folder base. It raises ValueError when the result would be anywhere else: a name that climbs out with .., an absolute path such as "/etc/passwd", a symbolic link inside base that leads out of it, and the folder base itself ("" or "."), which names nothing inside it. The path does not have to exist yet.
  • organise(folder, rules, dry_run=True) sorts the files directly inside folder into subfolders. rules maps a lower-case extension such as ".pdf" to a subfolder name such as "documents", and a file's extension is compared in lower case, so Report.PDF follows the ".pdf" rule. The function returns the planned moves as a list of (file name, subfolder name) pairs, sorted by file name. Folders, and files whose extension has no rule, stay where they are and are not listed. A file whose name already exists in its target subfolder is skipped and not listed: the function never overwrites anything. With dry_run=True nothing changes on disk; with dry_run=False the function also creates the subfolders it needs and moves the files.

Starter code · safe_paths.py

from pathlib import Path


def safe_join(base, user_path):
    """Return the resolved path that user_path names inside base, or raise ValueError."""
    # Replace these lines with your code.
    return Path(base) / user_path


def organise(folder, rules, dry_run=True):
    """Plan, and unless dry_run is true make, the moves of folder's files into subfolders."""
    # Replace these lines with your code.
    return []
The sample tests · test_safe_paths.py
import tempfile
from pathlib import Path

from safe_paths import organise, safe_join

RULES = {".pdf": "documents", ".jpg": "images", ".png": "images"}


def refused(base, name):
    try:
        safe_join(base, name)
    except ValueError:
        return True
    return False


def make_files(folder, names):
    for name in names:
        file = folder / name
        file.parent.mkdir(parents=True, exist_ok=True)
        file.write_text(name, encoding="utf-8")


def tree(folder):
    return sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())


def test_safe_join_inside():
    """returns the resolved path for names inside the folder, existing or not"""
    with tempfile.TemporaryDirectory() as tmp:
        base = Path(tmp) / "reports"
        make_files(base, ["q1/march.pdf"])
        assert safe_join(base, "q1/march.pdf") == base.resolve() / "q1" / "march.pdf"
        assert safe_join(str(base), "q2/new.pdf") == base.resolve() / "q2" / "new.pdf"
        assert safe_join(base, "q1/../q1/march.pdf") == base.resolve() / "q1" / "march.pdf"


def test_safe_join_refuses_escapes():
    """refuses .., absolute paths and the folder itself"""
    with tempfile.TemporaryDirectory() as tmp:
        base = Path(tmp) / "reports"
        base.mkdir()
        for name in ["../secret.txt", "../../etc/passwd", "/etc/passwd", "q1/../../outside.txt", "", "."]:
            assert refused(base, name), f"safe_join(base, {name!r}) should raise ValueError"


def test_safe_join_refuses_links_out():
    """refuses a symbolic link inside the folder that points out of it"""
    with tempfile.TemporaryDirectory() as tmp:
        base = Path(tmp) / "reports"
        base.mkdir()
        (Path(tmp) / "private").mkdir()
        (base / "shortcut").symlink_to(Path(tmp) / "private")
        assert refused(base, "shortcut/key.txt"), "a link that leads out of base must be refused"


def test_organise_dry_run():
    """plans the moves, sorted by file name, and changes nothing"""
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        make_files(folder, ["b.jpg", "Report.PDF", "notes.txt", "a.png"])
        before = tree(folder)
        assert organise(folder, RULES) == [("Report.PDF", "documents"), ("a.png", "images"), ("b.jpg", "images")]
        assert tree(folder) == before


def test_organise_moves_and_never_overwrites():
    """moves files into new or existing subfolders, skips names that exist and leaves other entries alone"""
    with tempfile.TemporaryDirectory() as tmp:
        folder = Path(tmp)
        make_files(folder, ["logo.png", "images/logo.png", "photo.JPG", "cv.pdf", "notes.txt", "music.pdf/readme.txt"])
        assert organise(folder, RULES, dry_run=False) == [("cv.pdf", "documents"), ("photo.JPG", "images")]
        assert tree(folder) == ["documents/cv.pdf", "images/logo.png", "images/photo.JPG", "logo.png", "music.pdf/readme.txt", "notes.txt"]
        assert (folder / "images" / "logo.png").read_text(encoding="utf-8") == "images/logo.png"
A hint

In safe_join, resolve both paths before you compare them: base = Path(base).resolve() and target = (base / user_path).resolve(). is_relative_to() only compares the parts of two paths as text, so it is reliable only after resolve() has removed .. and followed symbolic links; then also refuse target == base. In organise, loop over sorted(folder.iterdir()), keep entries for which is_file() is true and rules.get(p.suffix.lower()) gives a subfolder, and check (folder / sub / p.name).exists() before you plan a move.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The practice below covers building paths, relative paths, listing, file operations, names from outside and scratch folders. It opens with the idea you are least sure of, one question at a time, and each answer you pick is explained on the spot:

Practice that adapts to you

Practise this lesson

18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 p = Path("data") / "term-1" / "marks.final.csv". What is p.parent?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: data/term-1

    parent is the path without its last part, data/term-1, and parents lists every folder above it, down to "." for a relative path, as the example printed.

  2. Question 2 of 18 A script builds paths with folder + "/" + name, and folder comes from a setting such as "data/". What goes wrong that Path(folder) / name avoids?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: The separator is hard-coded, and slashes can double, while Path writes the system's own separator and joins parts cleanly

    Joining text by hand repeats a slash that the folder already ends with and always writes /, whatever the system. The / operator on Path objects drops the extra slash and writes the system's separator, and str(path) gives the text when something needs a string.

  3. Question 3 of 18 A program on a Linux server reads Windows paths such as C:\Users\asha\marks.csv from a file and needs each file's name. Which call gives marks.csv?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: PureWindowsPath(text).name

    Path follows the computer it runs on. To work with another system's paths, use PureWindowsPath or PurePosixPath, which never touch the disk; as_posix() writes any path with forward slashes.

  4. Question 4 of 18 A relative path such as data/marks.csv is resolved from which folder?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: The current working directory, by default the folder the program was started from

    A relative path is read from the current working directory each time it is used. That is the folder the program was started from, unless os.chdir() changed it.

  5. Question 5 of 18 The current folder holds a/b/notes.txt. What does this print?

    Read the code, then choose one answer.

    import os
    from pathlib import Path
    
    notes = Path("b") / "notes.txt"
    os.chdir("a")
    print(notes.exists())
    os.chdir("b")
    print(notes.exists())
    Show the answer: Question 5 of 18

    Answer: True False

    The Path object is the same text, b/notes.txt, on both lines. What it names depends on the current working directory at the moment it is used.

  6. Question 6 of 18 A command-line tool takes an output folder as an argument and later changes the current folder with os.chdir(). What should it do with the argument first?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: Turn it into an absolute path at once, with Path(arg).resolve()

    resolve() makes the path absolute against the current folder at that moment. After that, os.chdir() can no longer change what the path names.

  7. Question 7 of 18 In the project of listing.py, what does project.iterdir() return?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: README.md, main.py, src and tests, files and folders of one level

    iterdir() gives one Path per entry of one folder, files and folders alike, in no promised order, which is why the example sorts what it prints.

  8. Question 8 of 18 The project of listing.py has six files in three subfolders and the project folder itself. How many entries does project.rglob("*") give?

    Type a number.

    A hint

    The pattern * matches folder names as well as file names, at every depth.

    Show the answer: Question 8 of 18

    Answer: 9 entries

    rglob("*") matches every entry below the project, folders included: the six files plus src, src/data and tests, 9 in all. Filter with is_file() to keep only the files, as the largest-files example does.

  9. Question 9 of 18 A search uses for folder, subfolders, files in root.walk(): and must never go into folders named .git. Which line inside the loop does that?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: subfolders[:] = [d for d in subfolders if d != ".git"]

    walk() goes only into the subfolders whose names are still in its own list when the loop body ends, as long as it walks top-down. Change that list in place; making a new list changes nothing for walk().

  10. Question 10 of 18 What does folder.rmdir() do when the folder still contains files?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: It raises an OSError and deletes nothing

    unlink() deletes a file, rmdir() an empty folder, and shutil.rmtree() a folder with everything in it, with no confirmation and no undo.

  11. Question 11 of 18 What does organise.py print?

    What does this program print? Choose one answer.

    import tempfile
    from pathlib import Path
    
    RULES = {".pdf": "documents", ".docx": "documents", ".jpg": "images", ".png": "images", ".zip": "archives"}
    
    
    def plan(folder):
        """The moves organising would make, as (file, subfolder) pairs, and the files it must skip."""
        moves, skipped = [], []
        for file in sorted(folder.iterdir()):
            sub = RULES.get(file.suffix.lower())  # ".PDF" and ".pdf" are the same kind of file
            if not file.is_file() or sub is None:
                continue
            if (folder / sub / file.name).exists():
                skipped.append(f"{file.name} ({sub}/{file.name} exists)")  # never overwrite
            else:
                moves.append((file, sub))
        return moves, skipped
    
    
    with tempfile.TemporaryDirectory() as tmp:
        downloads = Path(tmp) / "downloads"
        (downloads / "images").mkdir(parents=True)
        for name in ["Report.PDF", "cv.docx", "beach.jpg", "logo.png", "backup.zip", "notes.txt", "images/logo.png"]:
            (downloads / name).write_text("x", encoding="utf-8")
    
        moves, skipped = plan(downloads)
        print("dry run, nothing moved yet:")
        for file, sub in moves:
            print(f"  {file.name} -> {sub}/")
        print("  skipped:", skipped)
    
        for file, sub in moves:
            target = downloads / sub
            target.mkdir(exist_ok=True)
            file.move_into(target)  # Python 3.14; shutil.move(file, target) on older versions
    
        print("after:", sorted(p.relative_to(downloads).as_posix() for p in downloads.rglob("*") if p.is_file()))
    Show the answer: Question 11 of 18

    Answer: it prints

    dry run, nothing moved yet:
      Report.PDF -> documents/
      backup.zip -> archives/
      beach.jpg -> images/
      cv.docx -> documents/
      skipped: ['logo.png (images/logo.png exists)']
    after: ['archives/backup.zip', 'documents/Report.PDF', 'documents/cv.docx', 'images/beach.jpg', 'images/logo.png', 'logo.png', 'notes.txt']

    The extension is compared in lower case, so Report.PDF goes to documents. logo.png is skipped because images/logo.png already exists, so it stays in the downloads folder, and notes.txt has no rule and stays too.

  12. Question 12 of 18 A backup script runs source.copy(Path("backup") / source.name) every night. What happens on the second night, when the backup copy already exists?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: The old backup copy is replaced by the new one, without a question

    copy(), copy_into(), move() and move_into() all replace an existing file at the target. To keep older backups, put the date or a counter in the target's name, or check exists() first and decide.

  13. Question 13 of 18 Which of these names, joined to base with /, can lead out of base without any symbolic link?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: "../../etc/passwd" and "/etc/passwd"

    Plain joining accepts both ways out: ".." parts that climb above the base, and absolute names that replace it. A symbolic link inside the base is a third way, which only resolve() reveals.

  14. Question 14 of 18 Does safe_join(base, "q1/../q1/march.pdf") accept the name or refuse it?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: It accepts it and returns base/q1/march.pdf, because the resolved path is inside base

    A check on the resolved path accepts every name that really stays inside the folder, however it is written, and refuses every one that leaves it. A check on the text, such as refusing "..", is both too strict and too weak: it refuses this name and accepts a link that leads out.

  15. Question 15 of 18 reports/shortcut is a symbolic link to a private folder elsewhere. Which check refuses the name "shortcut/key"?
    Show the answer: Question 15 of 18

    Answer: (base / name).resolve().is_relative_to(base.resolve()) is False, so refuse

    Only the file system knows where a link leads. resolve() asks it, and the comparison after it then sees the real location. Refuse the base itself too, which names no file inside it.

  16. Question 16 of 18 What does tempfile.TemporaryDirectory() do at the end of its with block?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: Deletes the folder it created and everything in it

    TemporaryDirectory creates a new, empty folder with a name nobody else uses, in the system's folder for temporary files, and deletes it with its contents when the with block ends.

  17. Question 17 of 18 Two copies of the same test run at the same time, and each uses tempfile.TemporaryDirectory(). Do they write into the same folder?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: No; each call creates a new folder with a name that nobody else uses

    Each TemporaryDirectory has a fresh, unique folder, which is what makes it safe for tests that run in parallel and for scripts that run twice at once.

  18. Question 18 of 18 A function must build a folder of report files and return its path, so that the caller can zip it later. Which tool fits?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: tempfile.mkdtemp(), deleting the folder yourself once the caller is done

    TemporaryDirectory is for work that ends with its block. When the result must outlive the block, mkdtemp() gives a unique folder that stays until your code deletes it.

Interview questions

What is the difference between Path.iterdir(), Path.glob() and Path.rglob()?

iterdir() lists the entries of one folder, files and subfolders alike, without looking inside the subfolders. glob(pattern) returns the entries that match a shell-style pattern relative to the folder: "*.csv" matches CSV files in the folder itself, and a ** part matches any number of folders, so "**/*.csv" searches the whole tree. rglob(pattern) is glob() with **/ in front of the pattern, so rglob("*.csv") finds CSV files at any depth. All three produce Path objects lazily and promise no order, so sort the results when order matters. To go through a tree folder by folder, with the names of the files and subfolders of each, use walk(), added in Python 3.12.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A report-download endpoint returns the bytes of Path("/srv/reports") / name, where name comes from the URL. A tester downloads /etc/passwd twice, once with name "../../etc/passwd" and once with "/etc/passwd". Explain both, and describe the check you would add, including what it must do about symbolic links.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A nightly clean-up job runs shutil.rmtree(Path(root) / cfg["build_dir"]). One night cfg["build_dir"] was an empty string, and the whole project folder was deleted. How do you make code that deletes files safe against bad configuration and mistakes?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Build paths with Path and /, and take them apart with name, stem, suffix, suffixes and parent; a Path never changes, the with_ methods return new ones.
  • Relative paths start at the current working directory, not at the script’s folder, so build paths to your own files from Path(__file__).resolve().parent.
  • iterdir() lists one folder, glob() and rglob() search with patterns, walk() visits a whole tree; none of them promise an order.
  • copy() and move() replace an existing file at the target, and unlink(), rmdir() and shutil.rmtree() delete with no undo: plan file operations, show the plan, then act.
  • For a name from outside, resolve the joined path and require is_relative_to(base): plain joins accept .., absolute paths and links that lead out.
  • tempfile.TemporaryDirectory() gives scripts and tests a private folder that deletes itself.

Terms in this lesson

current working directory
The folder that a program's relative paths start from: by default the folder the program was started from. Path.cwd() returns it, and os.chdir() changes it for the whole program.
dry run
A run that only reports what a program would do, such as the list of files it would move, and changes nothing. The real run then carries out the same plan.
glob pattern
A pattern for file names from the shell: * matches any run of characters within one name, ? one character, and in pathlib ** any number of folders, so **/*.csv finds CSV files at any depth.
path traversal
A weakness in which a name from outside, such as ../../etc/passwd, makes a program read or write a file outside the folder it meant to use. Resolving the joined path and checking that it is still inside the folder prevents it.
relative path
A path that does not start at a root, such as data/marks.csv. Python reads it from the current working directory each time it is used, not from the folder of the script that holds it.
symbolic link
A file that only points to another path. Opening it opens what it points to, so a link inside a folder can lead anywhere on the computer; Path.resolve() follows links to the real location.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.