Intermediate Python 📂 Modules · 2 of 6 42 min read

Python OS Module

Master Python's os module — the master key to your file system. Learn every essential method: getcwd, listdir, mkdir, makedirs, rename, remove, os.path.join, environment variables, and the star of the show — os.walk() with a full visual traversal diagram. Includes a path anatomy breakdown, five real-world scripts (disk usage, bulk rename, duplicate finder), pathlib comparison, and 7 golden rules for cross-platform safety.

Section 01

The Story That Explains the OS Module

The Janitor With Keys to Every Room
Imagine a huge office building. Guests use the front door. Employees use their assigned desks. But there's one person who can go anywhere — the janitor. They have the master key. They can unlock any office, list every room on every floor, know which lights are on, rename any nameplate, and if needed, remove furniture entirely.

That's what Python's os module gives you: a master key to the operating system. Read the current working directory. List a folder's contents. Create, rename, delete files. Walk a directory tree recursively. Read environment variables. Launch other programs.

You don't need it for pure computation — math and collections handle that. You need it the moment your Python program has to interact with the outside world: the file system, the shell, the environment it's running in.

The os module is Python's bridge to your operating system. Anything involving files, folders, paths, or environment configuration ultimately touches os. Its centrepiece — os.walk() — is the tool of choice for recursively traversing directory trees, and the reason many one-line scripts can process thousands of files.

💡
The Core Insight

Every function in os and os.path is a thin wrapper around a real system call. That's why it's cross-platform: Python translates os.mkdir("data") to the right call on Windows, macOS, and Linux. You write once; the OS-specific plumbing is handled for you.


Section 02

The Five Families of os Functions

The os module is huge, but every function falls into one of five families. Learn the shape of each family and you'll never guess which function to reach for.

📁
Directory Operations
Create, list, remove folders
getcwd, chdir, listdir, mkdir, makedirs, rmdir, removedirs. Everything to do with the folders themselves — creation, navigation, cleanup.
📄
File Operations
Rename, remove, inspect files
rename, remove, stat. For opening and reading actual content, use open() or pathlib — the os module is for the metadata and lifecycle around files.
🔗
Path Manipulation
os.path — build, split, check paths
os.path.join, split, basename, dirname, exists, splitext. String operations that respect operating-system rules (slashes, drives, etc).
🏠
Environment & Config
Read shell environment
os.environ, os.getenv. Read and set environment variables — API keys, config paths, debug flags. The bridge between your code and the shell that launched it.
🛠
Process & System
Run commands, get IDs
os.system, os.getpid, os.cpu_count, os.name. Launch other programs and query the running process. For serious subprocess work, use the subprocess module instead.
🔍
Traversal
os.walk — descend into trees
os.walk and os.scandir. The specialists for recursively visiting every file inside a directory tree. Covered in depth later — this is where the magic happens.

Section 03

Getting Oriented — Where Am I?

import os

# Where is my script running from?
print(os.getcwd())              # '/home/alice/projects/blog'

# What's in this folder?
print(os.listdir())              # ['posts', 'index.html', 'style.css']

# List a DIFFERENT folder without moving
print(os.listdir("/tmp"))         # files in /tmp

# Move into a folder (changes cwd for the whole process)
os.chdir("/tmp")
print(os.getcwd())              # '/tmp'

# Check if something exists (before touching it)
print(os.path.exists("data.csv"))       # True / False
print(os.path.isfile("data.csv"))       # True — is a file
print(os.path.isdir("reports"))        # True — is a directory
🔑
Always Check Before You Act

os.path.exists(), isfile(), isdir() are your safety net. Calling os.mkdir("data") when data already exists raises FileExistsError. Calling os.remove("logs") on a directory raises IsADirectoryError. A quick check first turns crashes into graceful branches.


Section 04

Creating & Removing Directories

import os

# Make ONE directory — fails if parent doesn't exist
os.mkdir("reports")

# Make a WHOLE CHAIN of directories in one call
os.makedirs("data/2025/january/raw")   # creates all four

# exist_ok=True — don't error if the folder already exists
os.makedirs("cache", exist_ok=True)

# Remove an EMPTY directory
os.rmdir("reports")              # fails if it has any content

# Remove a CHAIN of empty directories, bottom-up
os.removedirs("data/2025/january/raw")

# To remove a directory that ISN'T empty, use shutil
import shutil
shutil.rmtree("cache")             # nuclear option — no coming back
🧠 mkdir vs makedirs — Pick On Purpose
mkdir
One folder. Errors if parent missing or folder exists. Strict, precise.
makedirs
Chain of folders. Creates every missing parent. Use exist_ok=True to make it idempotent.
rmdir
Empty folder only. Errors if anything's inside — this is a feature, not a bug.
rmtree
From shutil. Recursively deletes everything inside. No undo. Use with extreme care.

Section 05

File Operations — Rename, Remove, Inspect

import os

# Rename or move a file (same call does both)
os.rename("draft.txt", "final.txt")
os.rename("final.txt", "archive/final.txt")  # cross-directory move

# Delete a file (permanent, no bin)
os.remove("temp.log")

# Get detailed metadata — size, timestamps, permissions
info = os.stat("main.py")
print(f"Size:     {info.st_size} bytes")
print(f"Modified: {info.st_mtime}")      # seconds since epoch
print(f"Mode:     {oct(info.st_mode)}")  # file permission bits

# Convert timestamp to a readable date
from datetime import datetime
modified = datetime.fromtimestamp(info.st_mtime)
print(f"Modified: {modified:%Y-%m-%d %H:%M}")
OUTPUT
Size: 1247 bytes Modified: 1741785600.0 Mode: 0o100644 Modified: 2026-01-15 14:20

Section 06

Path Anatomy — What Makes Up a Path

Every file path can be broken into named parts, and os.path has a function to extract each one. Understanding the anatomy makes the API self-evident.

Dissecting /home/alice/data/report.csv
/ home / alice / data / report .csv os.path.dirname() '/home/alice/data' os.path.basename() 'report.csv' stem (name) 'report' extension '.csv' root split() → (dirname, basename) splitext() → (stem, extension) join(a, b, c) → single path

os.path gives you named accessors for each region. join() assembles them back — with the right separator for your OS.

import os

path = "/home/alice/data/report.csv"

print(os.path.basename(path))     # 'report.csv'
print(os.path.dirname(path))      # '/home/alice/data'

# Split in one call — returns (dirname, basename)
print(os.path.split(path))
# ('/home/alice/data', 'report.csv')

# Split off the extension — returns (root, ext)
print(os.path.splitext(path))
# ('/home/alice/data/report', '.csv')

# Build a path — os.path.join() uses the right separator per OS
new_path = os.path.join("/home/alice", "reports", "2026", "jan.csv")
print(new_path)
# '/home/alice/reports/2026/jan.csv'   (or backslashes on Windows)

# Absolute path from a relative one
print(os.path.abspath("data.csv"))
🚨
Never Hardcode / or \\

"data/" + filename works on macOS/Linux but breaks on Windows. Always use os.path.join() — Python inserts the correct separator automatically. This one habit is the difference between a script that works on one machine and one that works everywhere.


Section 07

os.walk() — The Star of the Show

os.walk() recursively visits every subdirectory in a tree and yields three values for each folder it enters. Once you understand its yield shape, processing every file in a project becomes a five-line script.

The Yield Shape

import os

for dirpath, dirnames, filenames in os.walk("project"):
    print(dirpath)     # current folder (str)
    print(dirnames)    # sub-folders inside it (list of str)
    print(filenames)   # files inside it (list of str)
📑 What Each Value Represents
dirpath
Current folder path — a string like 'project/docs'
dirnames
Names of sub-folders inside dirpath — walk will descend into these next
filenames
Names of files inside dirpath (not sub-folders)
📈
These Are Names, Not Paths

dirnames and filenames contain bare names, not full paths. To open a file, you must join it with dirpath: full = os.path.join(dirpath, filename). Forgetting this is the most common os.walk bug.


Section 08

Visualising os.walk — Tree to Tuples

This is the diagram to burn into memory. On the left, a folder tree. On the right, what os.walk yields for each step, in order. The numbered dots show the traversal sequence — top-down, depth-first by default.

os.walk("project") — Step by Step
FOLDER TREE 1 2 3 project/ main.py docs/ intro.md guide.md src/ module.py utils.py README.md WHAT os.walk YIELDS 1 First yield — the root folder dirpath = 'project' dirnames = ['docs', 'src'] filenames = ['main.py', 'README.md'] 2 Descend into first subfolder dirpath = 'project/docs' dirnames = [] filenames = ['intro.md', 'guide.md'] 3 Descend into next subfolder dirpath = 'project/src' dirnames = [] filenames = ['module.py', 'utils.py'] Tree exhausted — loop ends 3 yields total — one per folder (root + 2 subfolders)

os.walk yields once per folder, top-down. Each yield is a (dirpath, dirnames, filenames) tuple. Purple numbers show the traversal order.

The Corresponding Code Output

import os

for dirpath, dirnames, filenames in os.walk("project"):
    print(f"Folder: {dirpath}")
    print(f"  Sub-folders: {dirnames}")
    print(f"  Files:       {filenames}")
    print()
OUTPUT
Folder: project Sub-folders: ['docs', 'src'] Files: ['main.py', 'README.md'] Folder: project/docs Sub-folders: [] Files: ['intro.md', 'guide.md'] Folder: project/src Sub-folders: [] Files: ['module.py', 'utils.py']

Section 09

Common os.walk Patterns

Pattern 1 — Full Paths of Every File

import os

for dirpath, _, filenames in os.walk("project"):
    for f in filenames:
        full_path = os.path.join(dirpath, f)
        print(full_path)

Pattern 2 — Only .py Files

import os

py_files = []
for dirpath, _, filenames in os.walk("project"):
    for f in filenames:
        if f.endswith(".py"):
            py_files.append(os.path.join(dirpath, f))

print(f"Found {len(py_files)} Python files")

Pattern 3 — Skip Folders In-Place

import os

# Modify dirnames IN PLACE to prune the walk
# This tells os.walk NOT to descend into those folders

skip = {".git", "__pycache__", "node_modules", ".venv"}

for dirpath, dirnames, filenames in os.walk("project"):
    # Remove skip-folders from dirnames — walk won't visit them
    dirnames[:] = [d for d in dirnames if d not in skip]

    for f in filenames:
        print(os.path.join(dirpath, f))
⚠️
The Slice Assignment Is Critical

Use dirnames[:] = [...], not dirnames = [...]. The slice assignment mutates the original list that os.walk is still using internally. Reassigning creates a new local list — walk keeps the old one and your prune is silently ignored. This one bug catches everyone once.

Pattern 4 — Bottom-Up Walk (For Deletion)

import os

# topdown=False visits the DEEPEST folders first
# Essential for cleanup — you can't rmdir a parent that still has children

for dirpath, dirnames, filenames in os.walk("temp_data", topdown=False):
    for f in filenames:
        os.remove(os.path.join(dirpath, f))
    os.rmdir(dirpath)                    # now empty, safe to remove

Section 10

Environment Variables

Environment variables are how the shell passes configuration to your program. API keys, database URLs, debug flags, PATH — all of it. Reading them with os is one line.

import os

# Direct access — raises KeyError if missing
home = os.environ["HOME"]
user = os.environ["USER"]

# Safe access with a default
db_url = os.getenv("DATABASE_URL", "sqlite:///dev.db")
debug  = os.getenv("DEBUG", "0") == "1"

# Set an environment variable for THIS process
os.environ["MY_APP_MODE"] = "production"

# Loop over every env var
for key, value in os.environ.items():
    if key.startswith("APP_"):
        print(f"{key}={value}")
🔑
Environment Variables Are Always Strings

os.getenv("PORT") returns "8080", not 8080. Convert deliberately: port = int(os.getenv("PORT", "8080")). Forgetting this is a silent type-mismatch bug.


Section 11

Process & System Info

import os

# Which OS is this? Useful for platform-specific branches
print(os.name)             # 'posix' on Linux/macOS, 'nt' on Windows

# Process info
print(os.getpid())          # current process ID
print(os.getppid())         # parent process ID

# Available CPUs — useful for parallel processing
print(os.cpu_count())       # e.g. 8

# Run a shell command (simple use only)
os.system("echo Hello from shell")

# For anything real, use subprocess — safer, captures output
import subprocess
result = subprocess.run(
    ["ls", "-la"], capture_output=True, text=True
)
print(result.stdout)

Section 12

Practical Real-World Examples

Example 1 — Count Files By Extension

import os
from collections import Counter

counts = Counter()
for _, _, filenames in os.walk("project"):
    for f in filenames:
        ext = os.path.splitext(f)[1].lower() or "(no ext)"
        counts[ext] += 1

for ext, n in counts.most_common():
    print(f"{ext:10s} {n}")
OUTPUT
.py 124 .md 38 .json 12 .txt 7 (no ext) 3

Example 2 — Total Disk Usage of a Folder

import os

def folder_size(root):
    total = 0
    for dirpath, _, filenames in os.walk(root):
        for f in filenames:
            path = os.path.join(dirpath, f)
            try:
                total += os.path.getsize(path)
            except OSError:
                pass                     # broken symlink, permission denied
    return total

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

Example 3 — Rename Files In Bulk

import os

# Rename every .jpeg to .jpg in a photo folder
for dirpath, _, filenames in os.walk("photos"):
    for f in filenames:
        if f.endswith(".jpeg"):
            old = os.path.join(dirpath, f)
            new = os.path.join(dirpath, f[:-5] + ".jpg")
            os.rename(old, new)
            print(f"{old}  →  {new}")

Example 4 — Find Files Modified Today

import os
from datetime import datetime, timedelta

cutoff = (datetime.now() - timedelta(days=1)).timestamp()

recent = []
for dirpath, _, filenames in os.walk("logs"):
    for f in filenames:
        path = os.path.join(dirpath, f)
        if os.path.getmtime(path) > cutoff:
            recent.append(path)

print(f"{len(recent)} file(s) modified in the last 24 hours")

Example 5 — Find Duplicate Filenames Across a Tree

import os
from collections import defaultdict

seen = defaultdict(list)

for dirpath, _, filenames in os.walk("project"):
    for f in filenames:
        seen[f].append(os.path.join(dirpath, f))

# Filenames appearing in more than one location
duplicates = {name: paths for name, paths in seen.items() if len(paths) > 1}

for name, paths in duplicates.items():
    print(f"{name} appears in:")
    for p in paths:
        print(f"  {p}")

Section 13

Modern Alternative — pathlib

Since Python 3.4, pathlib offers an object-oriented alternative to os.path. Same jobs, cleaner syntax. It doesn't fully replace os (there's no direct walk equivalent until 3.12's Path.walk()), but for path manipulation, pathlib is usually the better choice in new code.

📚 Old Way (os.path)
OperationCode
Joinos.path.join(a, b, c)
Basenameos.path.basename(p)
Extensionos.path.splitext(p)[1]
Existsos.path.exists(p)
Read textopen(p).read()
🌱 Modern Way (pathlib)
OperationCode
JoinPath(a) / b / c
Basenamep.name
Extensionp.suffix
Existsp.exists()
Read textp.read_text()
from pathlib import Path

p = Path("/home/alice/data/report.csv")

print(p.name)      # 'report.csv'
print(p.stem)      # 'report'
print(p.suffix)    # '.csv'
print(p.parent)    # PosixPath('/home/alice/data')

# The / operator joins paths
new_p = Path("/home/alice") / "reports" / "2026" / "jan.csv"

# Glob for files — often simpler than os.walk
for py_file in Path("project").rglob("*.py"):
    print(py_file)

Section 14

Quick Reference Table

FunctionPurposeExample
os.getcwd()Current working directory'/home/alice'
os.chdir(p)Change working directoryos.chdir("/tmp")
os.listdir(p)List folder contents (names only)['a.txt', 'sub']
os.mkdir(p)Create one directoryos.mkdir("data")
os.makedirs(p, exist_ok=True)Create nested directoriesos.makedirs("a/b/c")
os.rmdir(p)Remove empty directoryos.rmdir("empty")
os.rename(src, dst)Rename or move a fileos.rename("a", "b")
os.remove(p)Delete a fileos.remove("old.log")
os.stat(p)File metadata (size, times)info.st_size
os.walk(p)Recursively traverse treefor d, sd, f in walk(...)
os.path.join(*p)Build cross-platform pathsjoin("a", "b.txt")
os.path.split(p)Split into (dir, name)('/a', 'b.txt')
os.path.splitext(p)Split into (root, ext)('/a/b', '.txt')
os.path.basename(p)Filename portion'b.txt'
os.path.dirname(p)Directory portion'/a'
os.path.exists(p)Does it exist?True / False
os.path.isfile(p)Is it a file?True / False
os.path.isdir(p)Is it a directory?True / False
os.path.getsize(p)File size in bytes1247
os.environ[k]Read environment variableos.environ["HOME"]
os.getenv(k, default)Safe environment readgetenv("PORT", "8080")

Section 15

Common Pitfalls

⚠️
Hardcoding Separators
"data/" + name breaks on Windows
Always use os.path.join(). It inserts / on Unix and \\ on Windows. String concatenation is a portability bomb.
🚫
Forgetting to Join in os.walk
filenames are bare names
open(filename) inside os.walk fails because filename is just "a.txt", not the full path. Always os.path.join(dirpath, filename).
🛡
Reassigning dirnames
dirnames = [...] doesn't prune
Use dirnames[:] = [...] to mutate in place. Regular assignment creates a new local list and os.walk keeps using the old one. Your filter is silently ignored.
🔑
Env Vars Are Strings
os.getenv("PORT") ≠ 8080
Always convert deliberately: int(os.getenv("PORT", "8080")), os.getenv("DEBUG") == "1". Silent string-comparison bugs love this one.
🔥
shutil.rmtree Has No Undo
Recursive delete = permanent
There's no recycle bin, no confirmation. Always print the target path first, or use a dry-run flag. One typo can wipe a whole project folder.
🔔
os.system Is Weak
No stdout capture, shell-quoting bugs
For anything beyond a demo, use the subprocess module. It captures output, handles arguments safely, and returns rich error info.

Section 16

Golden Rules

🔑 OS Module — Non-Negotiable Rules
1
Always os.path.join() — never + or f-strings for paths. Cross-platform correctness comes for free. String concatenation on paths is a portability bug waiting to bite you the moment your code runs somewhere else.
2
Inside os.walk, always join filenames with dirpath. filenames are bare names, not paths. open(f) without os.path.join(dirpath, f) silently opens the wrong file — or none.
3
To prune folders from a walk, mutate dirnames in place with slice assignment. dirnames[:] = [d for d in dirnames if d not in skip]. Plain assignment is invisible to os.walk.
4
Use makedirs(path, exist_ok=True) for idempotent setup. No "does it exist?" check, no exception handling. One line, works whether the folder is there or not. This is the standard pattern.
5
Environment variables are always strings — convert them at the boundary. int(os.getenv("PORT", "8080")), os.getenv("DEBUG") == "1". Do the conversion once when you read, then use the typed value everywhere else.
6
Prefer pathlib in new code, os for traversal and legacy interop. Path("a") / "b" is cleaner than os.path.join("a", "b"). But os.walk and environment access still live in os — mixing both is fine.
7
Print or log destructive paths before deleting. shutil.rmtree and os.remove have no undo, no bin. A one-line preview turns a career-ending typo into a caught bug.