The Story That Explains the OS Module
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.
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.
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.
getcwd, chdir, listdir, mkdir,
makedirs, rmdir, removedirs. Everything
to do with the folders themselves — creation, navigation, cleanup.
rename, remove, stat. For opening and
reading actual content, use open() or pathlib — the
os module is for the metadata and lifecycle around files.
os.path.join, split, basename,
dirname, exists, splitext. String
operations that respect operating-system rules (slashes, drives, etc).
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.
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.
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.
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
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.
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
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}")
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.
/home/alice/data/report.csv
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"))
/ 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.
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)
dirpath — walk will descend into these next
dirpath (not sub-folders)
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.
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 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()
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))
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
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}")
os.getenv("PORT") returns "8080", not 8080.
Convert deliberately: port = int(os.getenv("PORT", "8080")).
Forgetting this is a silent type-mismatch bug.
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)
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}")
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}")
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.
| Operation | Code |
|---|---|
| Join | os.path.join(a, b, c) |
| Basename | os.path.basename(p) |
| Extension | os.path.splitext(p)[1] |
| Exists | os.path.exists(p) |
| Read text | open(p).read() |
| Operation | Code |
|---|---|
| Join | Path(a) / b / c |
| Basename | p.name |
| Extension | p.suffix |
| Exists | p.exists() |
| Read text | p.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)
Quick Reference Table
| Function | Purpose | Example |
|---|---|---|
os.getcwd() | Current working directory | '/home/alice' |
os.chdir(p) | Change working directory | os.chdir("/tmp") |
os.listdir(p) | List folder contents (names only) | ['a.txt', 'sub'] |
os.mkdir(p) | Create one directory | os.mkdir("data") |
os.makedirs(p, exist_ok=True) | Create nested directories | os.makedirs("a/b/c") |
os.rmdir(p) | Remove empty directory | os.rmdir("empty") |
os.rename(src, dst) | Rename or move a file | os.rename("a", "b") |
os.remove(p) | Delete a file | os.remove("old.log") |
os.stat(p) | File metadata (size, times) | info.st_size |
os.walk(p) | Recursively traverse tree | for d, sd, f in walk(...) |
os.path.join(*p) | Build cross-platform paths | join("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 bytes | 1247 |
os.environ[k] | Read environment variable | os.environ["HOME"] |
os.getenv(k, default) | Safe environment read | getenv("PORT", "8080") |
Common Pitfalls
os.path.join(). It inserts / on Unix
and \\ on Windows. String concatenation is a portability bomb.
open(filename) inside os.walk fails because
filename is just "a.txt", not the full path.
Always os.path.join(dirpath, filename).
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.
int(os.getenv("PORT", "8080")),
os.getenv("DEBUG") == "1". Silent string-comparison bugs
love this one.
print the target
path first, or use a dry-run flag. One typo can wipe a whole project folder.
subprocess module.
It captures output, handles arguments safely, and returns rich error info.
Golden Rules
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.
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.
dirnames in place with slice assignment.
dirnames[:] = [d for d in dirnames if d not in skip]. Plain
assignment is invisible to os.walk.
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.
int(os.getenv("PORT", "8080")), os.getenv("DEBUG") == "1".
Do the conversion once when you read, then use the typed value everywhere else.
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.
shutil.rmtree and os.remove have no undo, no bin.
A one-line preview turns a career-ending typo into a caught bug.