AlgoMaster Logo

os Module

Medium Priority30 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

For most modern code, pathlib is the better tool for working with file paths. The os module is the older, lower-level alternative, and it still earns its place for three reasons: a lot of existing libraries expect string paths, os.scandir is the fastest way to list a directory with metadata, and os.walk is the cleanest way to recursively traverse a directory tree with control over which subdirectories you enter. This lesson covers those tools alongside the rest of os and os.path that deals with files and directories.

Where You Are and Where You Go: getcwd and chdir

Every running Python process has a current working directory. Relative paths get resolved against it, so open("orders.csv") looks for the file in that directory and nowhere else. os.getcwd() returns it as a string, and os.chdir(path) changes it.

Changing the working directory affects every relative path the process uses after the call, which makes scripts harder to reason about when the directory shifts mid-run. A common rule: read os.getcwd() if you need it, but build absolute paths with os.path.join or pathlib.Path instead of changing the working directory. The one place chdir earns its keep is in CLI tools that mimic shell behavior (e.g., a cd command in a custom REPL).

Listing a Directory: listdir vs scandir

The two built-in ways to read the contents of a directory are os.listdir(path) and os.scandir(path). They look similar at first glance and behave very differently under the hood.

os.listdir(path) returns a plain list of names (strings), with no extra information:

The names are just strings. If you want to know whether each one is a file or a directory, or how big it is, you have to make another system call for each name (typically os.path.isfile or os.stat). That second round of calls is where the cost adds up.

os.scandir(path) returns an iterator of DirEntry objects. Each DirEntry carries the name, the full path, and cached file-type information from the directory listing itself, so checking entry.is_file() or entry.is_dir() usually needs no extra system call.

Two things to notice. First, scandir is used as a context manager (with os.scandir(...) as entries). The iterator holds an open directory handle on most platforms, and the with block guarantees it gets closed. Second, the entries arrive one at a time as you iterate, which means you can stop early without paying for the rest.

Here's the same task two ways. List every CSV in a daily-orders folder along with its size:

Both produce the same output. The second version makes fewer system calls because entry.is_file() reads the cached type and entry.stat() reuses the cached stat result on platforms that support it.

Creating and Removing Directories

os.mkdir(path) makes a single directory. It fails if the parent doesn't exist or if the directory already exists:

If /Users/maria/store exists but exports does not, this succeeds and the new directory is empty. If you try to create a nested path like /Users/maria/store/exports/2026/05 and any of 2026 or 05 doesn't already exist, os.mkdir raises FileNotFoundError.

os.makedirs(path, exist_ok=False) is the recursive version. It creates every missing directory in the path. The exist_ok flag controls what happens if the final directory already exists.

Without exist_ok=True, running this a second time raises FileExistsError. With it, the call succeeds silently. For directories that act as caches or output folders (where "already exists" is fine), exist_ok=True is the idiomatic choice.

What's wrong with this code?

If /Users/maria/store/exports doesn't exist yet, this raises:

os.mkdir only creates the leaf directory. The fix is os.makedirs for nested paths:

os.rmdir(path) removes a single empty directory. It will not remove a directory that contains anything:

If the directory has any files or subdirectories inside, this fails:

That's a deliberate safety check. Deleting a whole tree of files is destructive enough that Python forces you to ask for it explicitly. The high-level tool for "delete this directory and everything inside it" is shutil.rmtree.

os.removedirs(path) is the mirror of os.makedirs: it removes the leaf directory, then walks back up and removes each parent that becomes empty as a result. It still won't delete non-empty directories. If exports/2026/05 is empty and exports/2026 becomes empty after removing it, both go. If exports/2026 still has another sibling like 04, only 05 gets removed.

removedirs is useful for tidying up after a deeply nested temporary path, but most code reaches for shutil.rmtree when bulk deletion is the actual goal.

Removing and Renaming Files

os.remove(path) deletes a file. os.unlink(path) is an alias for it; the two names are interchangeable, and many codebases use unlink because that's the underlying system call name.

The call raises FileNotFoundError if the path doesn't exist, IsADirectoryError if the path is a directory (use rmdir for that), and PermissionError if the process doesn't have write permission on the parent directory.

os.rename(src, dst) moves a file or directory from src to dst. If dst already exists, behavior is platform-specific: on POSIX systems the existing file gets overwritten, on Windows the call raises FileExistsError. Since cross-platform code can't rely on either behavior, Python adds os.replace(src, dst), which always overwrites if dst exists, regardless of platform.

Both calls are atomic on a single filesystem, which means the rename either fully happens or doesn't happen at all. Code that "writes to a temporary name first, then renames into place" relies on this property to avoid leaving half-written files visible to other readers.

A reader that opens orders.json either sees the old version or the new version, never a partially written one. Without this pattern, a crash mid-write could leave the consumer reading half a JSON file.

Walking a Tree: os.walk

os.walk(top) is the function that has no clean equivalent in pathlib. It recursively traverses a directory tree, yielding one tuple per directory it visits.

Each tuple is (dirpath, dirnames, filenames):

  • dirpath is the path to the current directory being visited (a string).
  • dirnames is a list of subdirectory names directly inside dirpath (no leading paths).
  • filenames is a list of file names directly inside dirpath (no leading paths).

Here's what's happening visually:

The numbers in parentheses show the yield order with the default topdown=True. The root directory yields first, then each subdirectory in turn, and so on depth-first. With topdown=False, the deepest leaves yield first and the root yields last, which is the order you want when removing files because parent directories become empty only after their children are gone.

To build the full path of a file inside the walk, join dirpath with the file name:

That's the canonical pattern for "find every CSV under this tree", and it's what makes os.walk worth knowing even when you've adopted pathlib everywhere else. pathlib.Path.rglob("*.csv") does roughly the same job, but os.walk gives you the directory boundaries explicitly, which matters for the next trick.

Pruning the Walk by Mutating dirnames

Here's the trick that pathlib doesn't offer: when topdown=True, mutating the dirnames list in place tells os.walk which subdirectories to descend into next. Anything you remove from dirnames is skipped entirely.

The line dirnames[:] = [...] is the important one. Assigning dirnames = [...] would make dirnames point to a new list, which os.walk doesn't see. Slice-assignment with dirnames[:] mutates the same list the walker is holding, and the walker checks that list to decide which directories to enter next.

Common reasons to prune:

  • Skip hidden directories (.git, .cache, .venv).
  • Skip large generated subtrees you don't need (node_modules, __pycache__).
  • Skip directories you don't have permission to read.

The walker only descends into directories whose names survive the filter. Anything pruned is invisible to the rest of the traversal.

File Metadata: os.stat and Friends

os.stat(path) returns a stat_result object with file metadata: size, timestamps, permissions, owner, and a few other fields. It's the same data the ls -l shell command surfaces.

The timestamps are floats representing seconds since the Unix epoch (1970-01-01). To turn one into a readable date, pass it to datetime.fromtimestamp:

A note about st_ctime: on Linux it's the inode change time (when permissions or ownership changed), not the file creation time. On macOS and Windows it's closer to the creation time. If you need true creation time, prefer st_birthtime on macOS or Path(path).stat().st_ctime on Windows, and accept that on Linux this information typically isn't recorded by the filesystem.

For one-off questions, os.path has shortcut functions that read a single field of stat for you:

FunctionReturns
os.path.exists(p)True if p exists (file or directory)
os.path.isfile(p)True if p is a regular file
os.path.isdir(p)True if p is a directory
os.path.getsize(p)Size in bytes
os.path.getmtime(p)Modification time as a float
os.path.getctime(p)"Creation" time as a float (platform-dependent)

Each one is a thin wrapper around os.stat. If you need more than one field, call os.stat once and read several attributes off the result instead of making the system call repeatedly.

The difference is small for a single file. It matters when the same check runs for 50,000 files in a walk.

A common e-commerce use case is cleaning up old export folders. Here's a script that lists every order-export directory older than 90 days:

entry.stat() returns the same kind of stat_result as os.stat, but reuses the metadata cached by scandir where possible.

Building Paths with os.path

The os.path submodule has a handful of functions for assembling and dissecting path strings. They predate pathlib and are the right tool when you're talking to code that wants strings.

os.path.join

os.path.join glues path components together using the right separator for the platform (/ on POSIX, \ on Windows). Never concatenate paths with + or f-strings; os.path.join handles the trailing-slash edge case correctly.

One subtle behavior: if any component starts with a separator, os.path.join throws away everything before it and treats that component as the new root.

That's almost never what you want. If you're joining user-supplied components, validate them first or use pathlib.Path which raises an error on absolute child components when used with /.

os.path.split, splitext, basename, dirname

These four split a path into pieces:

FunctionWhat it returns
os.path.split(p)(head, tail) where tail is the last component and head is everything before it
os.path.splitext(p)(root, ext) where ext is the extension including the dot, or ""
os.path.basename(p)Just the last component (same as split(p)[1])
os.path.dirname(p)Everything before the last component (same as split(p)[0])

splitext is useful for grouping files by type. Here's a one-liner that builds a count of files by extension under a tree:

os.path.abspath and os.path.expanduser

os.path.abspath(p) turns a relative path into an absolute one by resolving it against the current working directory. It also collapses .. and . segments.

os.path.abspath does not check whether the path actually exists; it just normalizes the string. For "give me the real filesystem path with symlinks resolved" use os.path.realpath instead.

os.path.expanduser(p) expands a leading ~ (or ~user) into the user's home directory. Most CLI tools accept paths like ~/store/exports/orders.csv, and expanduser is what turns that into a usable path.

If the path doesn't start with ~, expanduser returns it unchanged. Calling it on every user-supplied path is safe and idiomatic.

Permissions: os.chmod

os.chmod(path, mode) changes the permission bits on a file or directory. The mode argument is an integer, almost always written in octal (the 0o prefix). The three trios of bits control read, write, and execute permission for the owner, the group, and everyone else.

The most common modes in everyday code are:

ModeMeaning
0o644Owner can read/write; group and others read only. Standard for data files.
0o755Owner can read/write/execute; group and others can read/execute. Standard for executables and directories.
0o600Owner can read/write; no one else has any access. Standard for private keys, credentials.
0o700Owner has full access; no one else has any. Standard for private directories.

These bits map directly to the POSIX permission model. On Windows the call is mostly a no-op for permissions: only the read-only bit is supported, and other bits get silently ignored. Code that ships cross-platform should treat os.chmod as "best-effort tightening", not a security guarantee. For real access control on Windows you'd reach for icacls or the win32security module.

The stat module exposes named constants (S_IRUSR = read by user, S_IWUSR = write by user, etc.) that bitwise-OR together to form the same value as the octal literal. Use whichever form your team finds clearer.

To read the current mode, use os.stat(path).st_mode and mask off the permission bits:

stat.S_IMODE keeps only the permission bits and discards the file-type bits (which encode whether it's a file, directory, symlink, etc.).

Environment Paths: os.environ and expandvars

os.environ is a dict-like object that exposes the process's environment variables. Reading and writing it is the standard way to pick up configuration from the shell.

os.environ["KEY"] raises KeyError if the variable isn't set. os.environ.get("KEY", default) returns the default instead, which is usually what config code wants.

Setting a variable in os.environ affects only the current process and its future child processes. It does not change the shell that launched Python.

os.path.expandvars(s) expands $VAR and ${VAR} references inside a string using the current environment. It's a useful companion to expanduser when paths come from config files written by humans:

If a referenced variable isn't set, expandvars leaves the $VAR text in place rather than raising. That's lenient but can hide typos in config; pair expandvars with an explicit check for any leftover $ in the result if that matters for your use case.

The idiomatic order for "read a user-supplied path from config and turn it into something openable" is:

Variables first (so ~ inside an expanded value still gets handled), then user, then absolute. That order isn't enforced by anything, but it produces the fewest surprises when paths are deeply parameterized.

When to Reach for pathlib Instead

A quick reference for the same operations in both modules:

Taskos / os.pathpathlib
Current directoryos.getcwd()Path.cwd()
List directoryos.listdir(p)Path(p).iterdir()
Check existsos.path.exists(p)Path(p).exists()
Join pathsos.path.join(a, b)Path(a) / b
Get extensionos.path.splitext(p)[1]Path(p).suffix
Make directory treeos.makedirs(p, exist_ok=True)Path(p).mkdir(parents=True, exist_ok=True)
File sizeos.path.getsize(p)Path(p).stat().st_size
Find all matches(use os.walk + filter)Path(p).rglob("*.csv")

For new code, pathlib is the better default. The os functions earn their place when:

  • You're handing paths to a library that expects strings (some older third-party packages still don't accept Path objects).
  • You need os.walk for recursive traversal with dirnames pruning.
  • You need the speed of os.scandir for very large directories.
  • You're reading or writing environment variables.

Mixing the two is fine. Path objects work as arguments to almost every os function, and most os functions return strings that you can wrap in Path(...) when you'd rather work with the object form.

Quiz

os Module Quiz

10 quizzes