Python os and pathlib Tutorial: Paths, Files and Directories

Build one small Python workspace from start to finish. Learn portable paths, file operations, directory walks, safe renames, environment variables and deliberate cleanup.

KnowledgeGate Team

Exam prep & CS education

Updated 1 Nov 20265 min read
Python os and pathlib Tutorial: Paths, Files and Directories

A path that works in one folder can fail as soon as a script moves, and slash-heavy strings make the failure harder to spot. Use one small pathlib_demo workspace to construct paths, create and find files, move them safely, bridge to os, and clean up. Run every snippet in sequence from a fresh working directory.

Python os and pathlib: what each module is for

Start with both modules:

python
import os
from pathlib import Path, PurePosixPath, PureWindowsPath

The official os documentation describes portable operating-system interfaces. The pathlib documentation defines path objects with useful methods and properties. The wider Coding & Skill Development Courses page groups Python with the wider programming sequence.

Task

Use

Construct or inspect a path

Path

Read, write, list, rename or delete a known entry

Path methods

Read the current directory or an environment variable

os.getcwd() or os.environ

Pass a path to a string-based API

os.fspath(path)

Inspect another platform's path rules without I/O

PureWindowsPath or PurePosixPath

Prefer Path for normal file operations, use os for the operating-system interface you need, and convert only at an API boundary. Neither module replaces the other.

The earlier File Handling in Python: Read, Write, Append, CSV and Binary Examples owns stream modes, cursor state, encodings, CSV quoting and binary bytes. A fixed scores dataset now moves through constructed paths, directory traversal, guarded renames, an environment-derived filename and cross-platform path objects.

Build and inspect paths without touching the filesystem

Path construction and inspection do not create a file:

python
report = Path("pathlib_demo") / "incoming" / "mock.v2.csv"

print(report.name)                 # mock.v2.csv
print(report.stem)                 # mock.v2
print(report.suffix)               # .csv
print(report.suffixes)             # ['.v2', '.csv']
print(report.parent.as_posix())    # pathlib_demo/incoming
print(report.with_name("mock.final.csv").as_posix())  # pathlib_demo/incoming/mock.final.csv
print(report.with_suffix(".json").as_posix())         # pathlib_demo/incoming/mock.v2.json

The / operator joins components while the path class handles separators. with_name() replaces the final name, while with_suffix() replaces only the final suffix. Avoid a literal such as "pathlib_demo\\incoming\\mock.v2.csv", because it embeds Windows separators. Path.cwd() depends on where the reader runs the script, so do not expect one fixed value.

Create directories, write files and check their state

Now create the workspace and two deterministic files:

python
root = Path("pathlib_demo")
incoming = root / "incoming"
processed = root / "processed"

incoming.mkdir(parents=True, exist_ok=True)
processed.mkdir(exist_ok=True)

score_text = "name,score\nAsha,84\nRavi,76\nMeera,91\n"
note_text = "OS calls, Path objects.\n"
print((incoming / "scores.csv").write_text(score_text, encoding="utf-8"))  # 36
print((incoming / "notes.txt").write_text(note_text, encoding="utf-8"))    # 24

parents=True creates missing parent directories. exist_ok=True accepts an existing directory, but not an existing non-directory at that path. write_text() returns character counts. These files are also 36 and 24 bytes because every character is ASCII.

python
print(root.exists())                              # True
print(incoming.is_dir())                          # True
print((incoming / "scores.csv").is_file())       # True
print(sorted(p.name for p in incoming.iterdir()))  # ['notes.txt', 'scores.csv']
print((incoming / "scores.csv").read_text(encoding="utf-8").splitlines())  # ['name,score', 'Asha,84', 'Ravi,76', 'Meera,91']
pathlib_demo tree with incoming CSV and notes files showing their exact text, byte counts, and checks.

List, filter and walk a directory tree

Use glob() for one directory and rglob() for recursive matching:

python
print(sorted(p.name for p in incoming.glob("*.csv")))  # ['scores.csv']
print(sorted(p.relative_to(root).as_posix()
             for p in root.rglob("*") if p.is_file()))  # ['incoming/notes.txt', 'incoming/scores.csv']

relative_to() gives a path relative to root. When a loop needs the current directory plus separate directory and file lists, use os.walk():

python
for current, dirs, files in os.walk(root):
    dirs.sort()
    files.sort()
    rel = Path(current).relative_to(root)
    print(rel.as_posix(), dirs, files)

The exact lines are . ['incoming', 'processed'] [], incoming [] ['notes.txt', 'scores.csv'], and processed [] []. Explicit sorting makes the teaching output deterministic. For the storage concepts behind these trees, read File Systems and Disk Scheduling in OS; that article does not teach Python APIs.

Rename and delete safely, with common errors visible

Move the CSV without silently overwriting a target:

python
source = incoming / "scores.csv"
target = processed / "scores-reviewed.csv"
if target.exists():
    raise FileExistsError(f"Refusing to overwrite {target}")
source.rename(target)

print(source.exists())                         # False
print(target.exists())                         # True
print(target.relative_to(root).as_posix())     # processed/scores-reviewed.csv

try:
    source.read_text(encoding="utf-8")
except FileNotFoundError:
    print("Source moved; read target")              # Source moved; read target

This check is a useful beginner guard in a single process, not an atomic concurrency guarantee. Three common errors now become visible:

  • Reading source raises FileNotFoundError; read target after the move. The preceding narrow catch reports that expected state without hiding unrelated errors.

  • Calling root.mkdir() without exist_ok=True raises FileExistsError; allow reuse only when intended.

  • Calling processed.rmdir() now raises OSError; rmdir() removes only an empty directory.

Remove only the known note, then its empty directory:

python
(incoming / "notes.txt").unlink()
incoming.rmdir()
print(incoming.exists())  # False

os.replace() is an explicitly overwrite-capable alternative. Read its exact platform semantics before using it.

Bridge pathlib to os and keep paths cross-platform

Path and os interoperate directly:

python
print(Path(os.getcwd()) == Path.cwd())  # True
legacy_path = os.fspath(target)

os.environ["KGAI_BATCH"] = "run-042"
print(os.getenv("KGAI_BATCH", "unassigned"))     # run-042
print(os.getenv("MISSING_BATCH", "unassigned")) # unassigned

os.fspath() returns the string or bytes form accepted by an older API, so its separators are platform-dependent. Changing os.environ affects this process and child processes launched later, not the user's permanent shell configuration.

Pure paths parse another platform's syntax without accessing its filesystem:

python
win = PureWindowsPath("C:/Users/Asha/report.csv")
print(win.drive, win.name)  # C: report.csv

posix = PurePosixPath("/home/asha/report.csv")
print(posix.root, posix.name)  # / report.csv

Join components with / on path objects. Do not assume a drive letter or leading slash, compare paths by lowercasing strings, or hard-code Path.home(), Path.cwd() or os.name in expected output.

Finish a small file-organising automation and practise

Continue from the current state and include the environment value in the final name:

python
batch = os.getenv("KGAI_BATCH", "unassigned")
final = processed / f"scores-{batch}.csv"
if final.exists():
    raise FileExistsError(f"Refusing to overwrite {final}")
target.rename(final)

data_rows = final.read_text(encoding="utf-8").splitlines()[1:]
print(final.relative_to(root).as_posix())  # processed/scores-run-042.csv
print(len(data_rows))                      # 3
print(final.stat().st_size)                # 36
print(data_rows)                           # ['Asha,84', 'Ravi,76', 'Meera,91']

This combines os.environ for run configuration with Path for naming, moving, reading and metadata.

Diagram shows scores.csv moving from incoming through a target-exists guard to processed, then to a run-specific file.

Try these before checking the answers:

  1. Inspect Path("archive") / "2026" / "mock.test.csv".

  2. List the CSV names in processed with glob().

  3. Set os.environ["KGAI_BATCH"] = "run-043", form the corresponding path under processed, and display it relative to root.

Answer checks:

  1. The name is mock.test.csv, stem is mock.test, suffixes are ['.test', '.csv'], and parent is archive/2026.

  2. sorted(p.name for p in processed.glob("*.csv")) returns ['scores-run-042.csv'].

  3. (processed / f"scores-{os.environ['KGAI_BATCH']}.csv").relative_to(root).as_posix() returns processed/scores-run-043.csv.

Finish by deleting only the demo paths this program created:

python
final.unlink()
processed.rmdir()
root.rmdir()
print(root.exists())  # False

Recursive deletion can remove every descendant, so validate the target before using a recursive tool.

The short version and the next Python step

Use Path and / to build paths, properties to inspect them, and mkdir(), read_text(), write_text(), glob(), rename() and unlink() for common filesystem work. Use os for environment variables, directory traversal and lower-level operating-system interfaces.

Work in a fresh demo directory, specify text encoding, sort output when order matters, check before a rename that must not overwrite, catch the specific expected exception, and remove only recognised paths. Build the language and standard-library foundation with the Python Course, then apply Python to data structures and interview problem solving with DSA Using Python.