Paths and Directories with pathlib

pathlib turns file paths into objects with methods. It replaces string joining and most of os.path, and it works the same on Windows, macOS and Linux.

Why not strings

import os

# Fragile: separators, duplicated slashes, platform differences
path = "data" + "/" + "reports" + "/" + "q3.txt"

# Better, but still a function call per operation
path = os.path.join("data", "reports", "q3.txt")

# Best
from pathlib import Path
path = Path("data") / "reports" / "q3.txt"
print(path)                     # data/reports/q3.txt (or with backslashes on Windows)

pathlib overloads the / operator to join path segments. Python inserts the correct separator for the platform, so the same code runs everywhere.

Building paths

from pathlib import Path

print(Path.cwd())                    # the current working directory
print(Path.home())                   # the user home directory
print(Path("notes.txt").resolve())   # the full absolute path

base = Path("project")
print(base / "src" / "main.py")
print(base.joinpath("docs", "readme.md"))

print(Path("~/notes").expanduser())  # expands the home shortcut

Taking a path apart

path = Path("/home/meera/reports/q3-summary.tar.gz")

print(path.name)          # q3-summary.tar.gz   - file name with extension
print(path.stem)          # q3-summary.tar      - name without the LAST suffix
print(path.suffix)        # .gz                 - the last extension only
print(path.suffixes)      # ['.tar', '.gz']
print(path.parent)        # /home/meera/reports
print(path.parents[1])    # /home/meera
print(path.parts)         # ('/', 'home', 'meera', 'reports', 'q3-summary.tar.gz')
print(path.is_absolute()) # True
path = Path("report.txt")

print(path.with_suffix(".md"))          # report.md
print(path.with_name("summary.txt"))    # summary.txt
print(path.with_stem("final"))          # final.txt   (Python 3.9+)

Asking about a path

path = Path("notes.txt")

print(path.exists())
print(path.is_file())
print(path.is_dir())
print(path.is_symlink())

if path.exists():
    info = path.stat()
    print(info.st_size, "bytes")
    from datetime import datetime
    print(datetime.fromtimestamp(info.st_mtime))

Reading and writing in one line

path = Path("notes.txt")

path.write_text("first line\nsecond line\n", encoding="utf-8")
print(path.read_text(encoding="utf-8"))

path.write_bytes(b"\x00\x01")
print(path.read_bytes())

# For anything larger, open it properly
with path.open(encoding="utf-8") as handle:
    for line in handle:
        print(line.rstrip())

read_text and write_text load the whole file at once. They are ideal for configuration and small data, and wrong for large files.

Creating and removing

folder = Path("data/reports/2026")

folder.mkdir(parents=True, exist_ok=True)     # create the whole chain, no error if present

file = folder / "q3.txt"
file.touch()                                  # create an empty file if absent

file.unlink()                                 # delete a file
file.unlink(missing_ok=True)                  # no error if it was not there

folder.rmdir()                                # only works on an EMPTY directory

import shutil
shutil.rmtree("data")                         # delete a directory and everything in it
shutil.rmtree deletes recursively with no confirmation and no undo. Print what you are about to delete before you run it, especially when the path is built from a variable.

Renaming and moving

source = Path("draft.txt")
source.rename("final.txt")               # fails if final.txt exists, on Windows

import os
os.replace("draft.txt", "final.txt")     # overwrites silently, atomic

import shutil
shutil.move("final.txt", "archive/")     # works across filesystems
shutil.copy("final.txt", "backup.txt")   # copy contents
shutil.copy2("final.txt", "backup.txt")  # copy contents and timestamps

Listing a directory

folder = Path("project")

for item in folder.iterdir():                 # one level only
    kind = "dir " if item.is_dir() else "file"
    print(kind, item.name)

print(sorted(p.name for p in folder.iterdir() if p.is_file()))

Matching by pattern

folder = Path("project")

for path in folder.glob("*.py"):              # this directory only
    print(path)

for path in folder.glob("**/*.py"):           # every subdirectory as well
    print(path)

for path in folder.rglob("*.py"):             # the same thing, shorter
    print(path)

for path in folder.glob("test_*.py"):
    print(path)
PatternMatches
*Any run of characters within one name
?Exactly one character
[abc]One character from the set
**This directory and all below it

Worked examples

Total size of a folder

def folder_size(folder):
    return sum(p.stat().st_size for p in Path(folder).rglob("*") if p.is_file())


size = folder_size("project")
print(f"{size:,} bytes  ({size / 1024 / 1024:.2f} MB)")

Counting files by extension

from collections import Counter


def extension_report(folder):
    counts = Counter(
        p.suffix.lower() or "(none)"
        for p in Path(folder).rglob("*")
        if p.is_file()
    )
    for extension, count in counts.most_common():
        print(f"{extension:<10}{count:>5}")


extension_report(".")

Organising files into folders by type

GROUPS = {
    "images": {".png", ".jpg", ".jpeg", ".gif"},
    "documents": {".pdf", ".txt", ".md", ".docx"},
    "archives": {".zip", ".tar", ".gz"},
}


def organise(folder, dry_run=True):
    folder = Path(folder)
    for path in folder.iterdir():
        if not path.is_file():
            continue
        for group, extensions in GROUPS.items():
            if path.suffix.lower() in extensions:
                target = folder / group
                print(f"{path.name}  ->  {group}/")
                if not dry_run:
                    target.mkdir(exist_ok=True)
                    path.rename(target / path.name)
                break


organise("downloads", dry_run=True)

Note the dry_run default. Any script that moves or deletes files should show what it intends to do before it does it.

Finding recently changed files

import time


def recent(folder, hours=24):
    cutoff = time.time() - hours * 3600
    return [
        p for p in Path(folder).rglob("*")
        if p.is_file() and p.stat().st_mtime > cutoff
    ]


for path in recent(".", hours=1):
    print(path)

Temporary files

import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as name:
    folder = Path(name)
    (folder / "scratch.txt").write_text("working", encoding="utf-8")
    print(list(folder.iterdir()))
# the whole directory is deleted here

with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as handle:
    handle.write("data")
    temp_path = Path(handle.name)

print(temp_path.read_text())
temp_path.unlink()

pathlib and os.path

os.pathpathlib
os.path.join(a, b)Path(a) / b
os.path.exists(p)Path(p).exists()
os.path.basename(p)Path(p).name
os.path.dirname(p)Path(p).parent
os.path.splitext(p)[1]Path(p).suffix
os.listdir(p)Path(p).iterdir()
os.makedirs(p, exist_ok=True)Path(p).mkdir(parents=True, exist_ok=True)
os.remove(p)Path(p).unlink()
os.walk(p)Path(p).rglob("*")

Every function that accepts a path string also accepts a Path, so the two can be mixed freely while migrating.

Common mistakes

  • Building paths by concatenating strings with / or \\.
  • Expecting stem to strip every suffix from archive.tar.gz.
  • Calling rmdir on a directory that is not empty.
  • Using rglob("*") on a huge tree without filtering, and waiting a long time.
  • Writing a file organiser with no dry run mode.
  • Checking exists() and then opening, when the file could vanish in between.

Best practices

  • Use pathlib for all new code.
  • Build paths with /, never with string concatenation.
  • Use mkdir(parents=True, exist_ok=True) rather than checking first.
  • Give every destructive script a dry run mode that is the default.
  • Use tempfile for scratch files rather than writing into the current directory.

Practice

  1. Report the five largest files under a directory tree.
  2. Find every file changed in the last day and group them by extension.
  3. Write a function that safely creates a nested folder structure and reports what it created.
  4. Rename every .txt file in a folder to .md, with a dry run mode.
  5. Explain why Path("a.tar.gz").stem is "a.tar" and write a function returning "a".

Conclusion

Treat a path as an object rather than a string. pathlib gives you joining with /, the parts by name, existence checks, directory creation, pattern matching with glob and recursive walking with rglob - all of it identical across platforms.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.