os Module Complexity¶
The os module is a thin layer over the operating system: most of its
functions are one syscall with argument marshalling around them.
A syscall counts as O(1) here. The kernel still resolves a path component by component and a write still moves its bytes, but that cost is not what a caller chooses between when picking one function over another. Where the kernel's work does scale with an argument — a byte count, a buffer list, a directory's contents, a flush — the row says so.
Size variables are named per row. The recurring pair is n, the number of
entries or components an operation handles, and L, the length of a path in
characters. Several rows need both, because a path's characters have to be
produced before anything can be done with them.
Complexity Reference¶
Path inspection¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.stat(path) |
O(1) | O(1) | One syscall, following symlinks |
os.lstat(path) |
O(1) | O(1) | Describes the link itself |
os.access(path, mode) |
O(1) | O(1) | effective_ids=True needs os.supports_effective_ids |
os.path.exists(path) |
O(1) | O(1) | One stat |
os.path.lexists(path) |
O(1) | O(1) | One lstat; true for a broken symlink |
os.path.isfile(path) |
O(1) | O(1) | One stat |
os.path.isdir(path) |
O(1) | O(1) | One stat |
os.path.islink(path) |
O(1) | O(1) | One lstat |
os.path.ismount(path) |
O(R) | O(R) | From 3.13 two lstat calls over O(L) of text; through 3.12 a realpath() of the parent runs first, carrying its cost |
os.path.getsize(path) |
O(1) | O(1) | One stat |
os.path.getatime(path) |
O(1) | O(1) | One stat |
os.path.getmtime(path) |
O(1) | O(1) | One stat |
os.path.getctime(path) |
O(1) | O(1) | Metadata change time on POSIX, creation time on Windows |
os.statvfs(path) |
O(1) | O(1) | Filesystem-wide counters |
os.fstatvfs(fd) |
O(1) | O(1) |
Directory listing and traversal¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.listdir(path) |
O(n) | O(n) | n = entries; every name is materialised |
os.scandir(path) |
O(n) | O(1) | n = entries over a full iteration; one entry is live at a time |
os.walk(path) |
O(n) | O(P) | n = entries visited, from 3.12; P = total length of the paths held at once. Through 3.11 each result is forwarded through one generator frame per level, so a chain of depth d costs O(n·d) |
os.fwalk(path) |
O(n) | O(P) | As os.walk(), and holds one open directory descriptor per level |
From 3.12 the traversal is one visit per entry, so the time column is n.
Through 3.11 it is not: yield from hands every result up through one
suspended frame per level, so walking a chain costs a factor of its depth on
top. That is the same rewrite that moved the space term, and it moves both.
The shape shows up in the clock even from 3.12, because the kernel resolves a deeper path on every call and this page counts a syscall as O(1). At the same entry count a deep tree is measurably slower than a wide one, and the bound does not say so.
Space is a different story, and entry count does not bound it at all: what is held is paths, not entries. Which shape costs the most depends on the version.
Through 3.11 os.walk() recurses once per level, suspending a frame that holds
that level's path, so depth dominates the peak there and a deep enough tree raises
RecursionError instead of finishing. From 3.12 it drives an explicit stack, so
for the default top-down walk breadth dominates instead and depth contributes
only the path being walked. topdown=False keeps each ancestor's result on that
stack until its descendants are done, which puts depth back into the peak.
Creating, removing and linking¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.mkdir(path) |
O(1) | O(1) | One syscall; the parent must exist |
os.makedirs(path) |
O(n·L) | O(n·L) | n = components that do not exist yet, L = path length; it recurses down to the first existing head, each frame holding its own prefix, so enough missing components raise RecursionError |
os.rmdir(path) |
O(1) | O(1) | The directory must be empty |
os.removedirs(path) |
O(n·L) | O(L) | n = components it manages to remove; iterative, so one prefix is live at a time |
os.remove(path) |
O(1) | O(1) | |
os.unlink(path) |
O(1) | O(1) | The traditional Unix name; semantically identical to os.remove() |
os.rename(src, dst) |
O(1) | O(1) | Moves no bytes; fails across filesystems |
os.replace(src, dst) |
O(1) | O(1) | Overwrites dst |
os.renames(old, new) |
O(n·L) | O(n·L) | makedirs() on the new head, then rename(), then removedirs() on the old |
os.link(src, dst) |
O(1) | O(1) | |
os.symlink(src, dst) |
O(1) | O(1) | |
os.readlink(path) |
O(t) | O(t) | t = length of the stored target |
os.truncate(path, length) |
O(1) | O(1) | One syscall whatever length is |
os.ftruncate(fd, length) |
O(1) | O(1) | |
os.mkfifo(path) |
O(1) | O(1) | |
os.mknod(path) |
O(1) | O(1) | |
os.chmod(path, mode) |
O(1) | O(1) | |
os.fchmod(fd, mode) |
O(1) | O(1) | |
os.lchmod(path, mode) |
O(1) | O(1) | Where the platform has it; not on Linux |
os.chown(path, uid, gid) |
O(1) | O(1) | |
os.fchown(fd, uid, gid) |
O(1) | O(1) | |
os.lchown(path, uid, gid) |
O(1) | O(1) | Does not follow the link |
os.chflags(path, flags) |
O(1) | O(1) | BSD and macOS |
os.lchflags(path, flags) |
O(1) | O(1) | BSD and macOS |
os.utime(path, times) |
O(1) | O(1) | |
os.chroot(path) |
O(1) | O(1) |
File descriptors¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.close(fd) |
O(1) | O(1) | |
os.closerange(fd_low, fd_high) |
O(k) | O(1) | k = fd_high - fd_low; every descriptor in the span is closed and none outside it. Where the platform lacks close_range() the fallback really does try each one |
os.dup(fd) |
O(1) | O(1) | |
os.dup2(fd, fd2) |
O(1) | O(1) | |
os.fdopen(fd, ...) |
O(1) | O(1) | Wraps the descriptor; reads nothing |
os.pipe() |
O(1) | O(1) | |
os.pipe2(flags) |
O(1) | O(1) | |
os.eventfd(initval) |
O(1) | O(1) | Linux |
os.eventfd_read(fd) |
O(1) | O(1) | |
os.eventfd_write(fd, value) |
O(1) | O(1) | |
os.memfd_create(name) |
O(1) | O(1) | Linux |
os.timerfd_create(clockid) |
O(1) | O(1) | Linux, 3.13+ |
os.timerfd_settime(fd, ...) |
O(1) | O(1) | |
os.timerfd_settime_ns(fd, ...) |
O(1) | O(1) | |
os.timerfd_gettime(fd) |
O(1) | O(1) | |
os.timerfd_gettime_ns(fd) |
O(1) | O(1) | |
os.pidfd_open(pid) |
O(1) | O(1) | Linux |
os.isatty(fd) |
O(1) | O(1) | |
os.get_blocking(fd) |
O(1) | O(1) | |
os.set_blocking(fd, blocking) |
O(1) | O(1) | |
os.get_inheritable(fd) |
O(1) | O(1) | |
os.set_inheritable(fd, inheritable) |
O(1) | O(1) | |
os.get_handle_inheritable(handle) |
O(1) | O(1) | Windows |
os.set_handle_inheritable(handle, inheritable) |
O(1) | O(1) | Windows |
os.fchdir(fd) |
O(1) | O(1) | |
os.lockf(fd, cmd, len) |
O(1) | O(1) | |
os.device_encoding(fd) |
O(1) | O(1) | None when the descriptor is not a terminal |
Reading and writing descriptors¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.read(fd, n) |
O(n) | O(n) | n = bytes requested; allocates the result |
os.readinto(fd, buffer) |
O(n) | O(1) | n = the buffer's size in bytes; fills the caller's buffer rather than allocating, 3.14+ |
os.readv(fd, buffers) |
O(n + k) | O(k) | n = total bytes, k = buffers; the vector itself costs per buffer |
os.pread(fd, n, offset) |
O(n) | O(n) | Does not move the file offset |
os.preadv(fd, buffers, offset) |
O(n + k) | O(k) | |
os.write(fd, data) |
O(n) | O(1) | n = bytes written |
os.writev(fd, buffers) |
O(n + k) | O(k) | n = total bytes, k = buffers |
os.pwrite(fd, data, offset) |
O(n) | O(1) | |
os.pwritev(fd, buffers, offset) |
O(n + k) | O(k) | |
os.lseek(fd, pos, whence) |
O(1) | O(1) | |
os.sendfile(out, in, offset, count) |
O(count) | O(1) | Copies in the kernel; no user-space buffer |
os.copy_file_range(src, dst, count) |
O(count) | O(1) | Same, within one filesystem |
os.splice(src, dst, count) |
O(count) | O(1) | Same, through a pipe |
os.posix_fadvise(fd, offset, len, advice) |
O(1) | O(1) | Advisory; the kernel may act later |
os.posix_fallocate(fd, offset, len) |
O(len) | O(1) | Reserves the blocks before returning |
os.fsync(fd) |
O(1) | O(1) | One syscall; blocks until the file's dirty data is written |
os.fdatasync(fd) |
O(1) | O(1) | As fsync(), skipping metadata that is not needed to read the data back |
os.sync() |
O(1) | O(1) | Blocks on every filesystem's dirty data |
os.urandom(size) |
O(size) | O(size) | |
os.getrandom(size) |
O(size) | O(size) | Linux |
Terminals and pseudo-terminals¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.ctermid() |
O(1) | O(1) | |
os.ttyname(fd) |
O(n) | O(n) | n = length of the returned name |
os.get_terminal_size(fd) |
O(1) | O(1) | One ioctl |
os.tcgetpgrp(fd) |
O(1) | O(1) | |
os.tcsetpgrp(fd, pg) |
O(1) | O(1) | |
os.openpty() |
O(1) | O(1) | |
os.login_tty(fd) |
O(1) | O(1) | 3.11+ |
os.posix_openpt(oflag) |
O(1) | O(1) | 3.13+ |
os.grantpt(fd) |
O(1) | O(1) | 3.13+ |
os.unlockpt(fd) |
O(1) | O(1) | 3.13+ |
os.ptsname(fd) |
O(n) | O(n) | n = length of the returned name; 3.13+ |
Process creation and exit¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.fork() |
O(1) | O(1) | Excluding registered handlers; the kernel's page-table copy scales with the parent's mappings |
os.forkpty() |
O(1) | O(1) | fork() plus a pty pair |
os.register_at_fork(...) |
O(1) | O(1) | Appends a handler; each fork() then pays for every handler registered, which is what the row above excludes |
os.execv(path, args) |
O(a) | O(a) | a = total bytes of the argument vector |
os.execve(path, args, env) |
O(a + e) | O(a + e) | e = total bytes of the environment |
os.execl(path, *args) |
O(a) | O(a) | Same, with the arguments spelled inline |
os.execle(path, *args, env) |
O(a + e) | O(a + e) | |
os.execvp(file, args) |
O(k·a + p) | O(a + p) | p = length of PATH, k = entries tried; every failed attempt marshals the arguments again |
os.execvpe(file, args, env) |
O(k·(a + e) + p) | O(a + e + p) | The environment is re-marshalled per attempt too |
os.execlp(file, *args) |
O(k·a + p) | O(a + p) | |
os.execlpe(file, *args, env) |
O(k·(a + e) + p) | O(a + e + p) | |
os.spawnv(mode, path, args) |
O(a) | O(a) | fork() plus the matching exec |
os.spawnve(mode, path, args, env) |
O(a + e) | O(a + e) | |
os.spawnl(mode, path, *args) |
O(a) | O(a) | |
os.spawnle(mode, path, *args, env) |
O(a + e) | O(a + e) | |
os.spawnvp(mode, file, args) |
O(k·a + p) | O(a + p) | |
os.spawnvpe(mode, file, args, env) |
O(k·(a + e) + p) | O(a + e + p) | |
os.spawnlp(mode, file, *args) |
O(k·a + p) | O(a + p) | |
os.spawnlpe(mode, file, *args, env) |
O(k·(a + e) + p) | O(a + e + p) | |
os.posix_spawn(path, argv, env) |
O(a + e + f) | O(a + e + f) | f = file actions |
os.posix_spawnp(file, argv, env) |
O(a + e + f + p) | O(a + e + f + p) | The PATH search happens in the C library, not per Python-level attempt |
os.popen(cmd) |
O(c) | O(c) | c = command length; starts a shell and returns a file object, the command runs concurrently |
os.system(cmd) |
Varies | O(c) | Runs cmd in a shell and blocks until it exits, so the time is the command's |
os.kill(pid, sig) |
O(1) | O(1) | |
os.killpg(pgid, sig) |
O(1) | O(1) | |
os.abort() |
O(1) | O(1) | Does not return |
os._exit(n) |
O(1) | O(1) | Skips cleanup; does not return |
os.startfile(path) |
O(1) | O(1) | Windows |
Waiting and exit status¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.wait() |
O(1) | O(1) | One syscall; blocks until a child exits |
os.waitpid(pid, options) |
O(1) | O(1) | |
os.wait3(options) |
O(1) | O(1) | Also returns resource usage |
os.wait4(pid, options) |
O(1) | O(1) | |
os.waitid(idtype, id, options) |
O(1) | O(1) | |
os.waitstatus_to_exitcode(status) |
O(1) | O(1) | |
os.WIFEXITED(status) |
O(1) | O(1) | Bit test on an integer |
os.WEXITSTATUS(status) |
O(1) | O(1) | |
os.WIFSIGNALED(status) |
O(1) | O(1) | |
os.WTERMSIG(status) |
O(1) | O(1) | |
os.WCOREDUMP(status) |
O(1) | O(1) | |
os.WIFSTOPPED(status) |
O(1) | O(1) | |
os.WSTOPSIG(status) |
O(1) | O(1) | |
os.WIFCONTINUED(status) |
O(1) | O(1) |
Process attributes and priority¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.getpid() |
O(1) | O(1) | |
os.getppid() |
O(1) | O(1) | |
os.getcwd() |
O(L) | O(L) | L = length of the working directory |
os.getcwdb() |
O(L) | O(L) | Same, as bytes |
os.chdir(path) |
O(1) | O(1) | |
os.umask(mask) |
O(1) | O(1) | |
os.nice(increment) |
O(1) | O(1) | |
os.getpriority(which, who) |
O(1) | O(1) | |
os.setpriority(which, who, priority) |
O(1) | O(1) | |
os.times() |
O(1) | O(1) | |
os.getloadavg() |
O(1) | O(1) | |
os.uname() |
O(1) | O(1) | |
os.cpu_count() |
O(1) | O(1) | Machine-wide count |
os.process_cpu_count() |
O(c) | O(c) | Counts the affinity mask, so it carries sched_getaffinity()'s cost; 3.13+ |
os.plock(op) |
O(1) | O(1) | Where the platform has it |
os.setns(fd) |
O(1) | O(1) | Linux, 3.12+ |
os.unshare(flags) |
O(1) | O(1) | Linux, 3.12+ |
User, group and session IDs¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.getuid() |
O(1) | O(1) | |
os.geteuid() |
O(1) | O(1) | |
os.getgid() |
O(1) | O(1) | |
os.getegid() |
O(1) | O(1) | |
os.getresuid() |
O(1) | O(1) | |
os.getresgid() |
O(1) | O(1) | |
os.setuid(uid) |
O(1) | O(1) | |
os.seteuid(euid) |
O(1) | O(1) | |
os.setgid(gid) |
O(1) | O(1) | |
os.setegid(egid) |
O(1) | O(1) | |
os.setreuid(ruid, euid) |
O(1) | O(1) | |
os.setregid(rgid, egid) |
O(1) | O(1) | |
os.setresuid(ruid, euid, suid) |
O(1) | O(1) | |
os.setresgid(rgid, egid, sgid) |
O(1) | O(1) | |
os.getgroups() |
O(g) | O(g) | g = supplementary groups |
os.setgroups(groups) |
O(g) | O(g) | |
os.getgrouplist(user, group) |
Varies | O(g) | g = groups returned; consults the group database, see below |
os.initgroups(username, gid) |
Varies | O(g) | Same |
os.getlogin() |
O(n) | O(n) | n = login name; reads the terminal's login record |
os.getpgid(pid) |
O(1) | O(1) | |
os.getpgrp() |
O(1) | O(1) | |
os.setpgid(pid, pgrp) |
O(1) | O(1) | |
os.setpgrp() |
O(1) | O(1) | |
os.getsid(pid) |
O(1) | O(1) | |
os.setsid() |
O(1) | O(1) |
os.getgrouplist() and os.initgroups() have no bound this page can state.
They resolve through the platform's name-service switch, so the cost belongs to
whichever backend is configured — a local file, a directory server over the
network, or a cache in front of either.
Scheduling¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.sched_getaffinity(pid) |
O(c) | O(c) | c = the highest CPU id the mask spans, which sizes the bitmap; builds a set of the bits that are on |
os.sched_setaffinity(pid, mask) |
O(c) | O(c) | Same c: one high id costs as much as every lower one set |
os.sched_yield() |
O(1) | O(1) | |
os.sched_getscheduler(pid) |
O(1) | O(1) | |
os.sched_setscheduler(pid, policy, param) |
O(1) | O(1) | |
os.sched_getparam(pid) |
O(1) | O(1) | |
os.sched_setparam(pid, param) |
O(1) | O(1) | |
os.sched_get_priority_min(policy) |
O(1) | O(1) | |
os.sched_get_priority_max(policy) |
O(1) | O(1) | |
os.sched_rr_get_interval(pid) |
O(1) | O(1) |
Extended attributes¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.getxattr(path, attribute) |
O(v) | O(v) | v = length of the stored value |
os.setxattr(path, attribute, value) |
O(v) | O(1) | |
os.listxattr(path) |
O(b) | O(b) | b = total bytes of the attribute names |
os.removexattr(path, attribute) |
O(1) | O(1) |
System configuration¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.sysconf(name) |
O(1) | O(1) | |
os.confstr(name) |
O(n) | O(n) | n = length of the returned string |
os.pathconf(path, name) |
O(1) | O(1) | |
os.fpathconf(fd, name) |
O(1) | O(1) | |
os.sysconf_names |
O(1) | O(1) | A dict; lookup, not a scan |
os.confstr_names |
O(1) | O(1) | A dict |
os.pathconf_names |
O(1) | O(1) | A dict |
os.supports_dir_fd |
O(1) | O(1) | A set; membership test |
os.supports_fd |
O(1) | O(1) | A set |
os.supports_follow_symlinks |
O(1) | O(1) | A set |
os.supports_effective_ids |
O(1) | O(1) | A set |
os.strerror(code) |
O(m) | O(m) | m = length of the message |
os.major(device) |
O(1) | O(1) | Arithmetic on the device number |
os.minor(device) |
O(1) | O(1) | |
os.makedev(major, minor) |
O(1) | O(1) | |
os.listdrives() |
O(k) | O(k) | k = drives; Windows, 3.12+ |
os.listvolumes() |
O(k) | O(k) | Windows, 3.12+ |
os.listmounts(volume) |
O(k) | O(k) | Windows, 3.12+ |
os.add_dll_directory(path) |
O(1) | O(1) | Windows |
Environment¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.environ[key] |
O(k + v) | O(k + v) | k = key length, v = value length; a dict lookup in a snapshot taken at import. On POSIX the key is encoded and the value decoded around it; Windows stores str and hands it straight back |
os.environ.get(key, default) |
O(k + v) | O(k + v) | |
os.environ[key] = value |
O(k + v) | O(k + v) | Encodes both, then calls putenv(), so the setenv() scan below applies here too |
os.environ.items() |
O(1) | O(1) | A view; iterating it snapshots the keys, so consuming it is O(m) in time and space for m variables, plus the decoding |
os.environb |
O(k) | O(1) | The same mapping keyed by bytes; the key still has to be hashed, but nothing is encoded or decoded |
os.getenv(key) |
O(k + v) | O(k + v) | Reads os.environ |
os.getenvb(key) |
O(k) | O(1) | Reads os.environb; the value is handed back as stored, however long it is |
os.putenv(key, value) |
O(k + v) | O(k + v) | Changes the process environment, not os.environ; the C library's setenv() scans it, which a very large environment adds to |
os.unsetenv(key) |
O(k) | O(k) | Likewise, and unsetenv() scans it too |
os.reload_environ() |
O(b) | O(b) | b = total size of the environment, not just the variable count; rebuilds the snapshot, 3.14+ |
os.get_exec_path(env) |
O(p) | O(p) | p = length of PATH |
Path manipulation¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.path.join(path, *paths) |
O(L) | O(L) | L = total length of the arguments; a later absolute component discards the prefix but was still scanned |
os.path.split(path) |
O(L) | O(L) | |
os.path.dirname(path) |
O(L) | O(L) | |
os.path.basename(path) |
O(L) | O(L) | |
os.path.splitext(path) |
O(L) | O(L) | |
os.path.normpath(path) |
O(L) | O(L) | |
os.path.isabs(path) |
O(1) | O(1) | Inspects the prefix only; Windows before 3.13 rewrites separators across the whole path first, making it O(L) there |
os.path.normcase(path) |
O(L) | O(L) | O(1) on POSIX, which returns the argument unchanged; Windows case-folds it |
os.path.splitdrive(path) |
O(L) | O(L) | O(1) on POSIX, which returns the argument as the tail; a Windows UNC prefix is scanned and sliced |
os.path.splitroot(path) |
O(L) | O(L) | 3.12+; same shape as splitdrive() |
os.path.isreserved(path) |
O(L) | O(L) | Splits the path into components; Windows, 3.13+ |
os.path.relpath(path, start) |
O(L + S + C) | O(L + S + C) | S = length of start; one getcwd() per relative argument, so two when both are |
os.path.commonpath(paths) |
O(B) | O(B) | B = total length of every path given |
os.path.commonprefix(paths) |
O(B) | O(B) | Character-wise, so it can end mid-component |
os.path.expandvars(path) |
O(L + s) | O(L + s) | s = total length of the values substituted in |
os.path.expanduser(path) |
O(L + H) | O(L + H) | H = the home directory spliced in; ~user, and a bare ~ with no HOME set, consult the password database at whatever that backend costs |
os.path.abspath(path) |
O(L + C) | O(L + C) | C = length of the working directory, which a relative path is prefixed with after one getcwd() |
os.path.realpath(path) |
O(R) | O(R) | R = the path text walked: the argument (rooted at the working directory if relative), plus every symlink target spliced into it; one lstat per component |
os.path.samefile(p1, p2) |
O(1) | O(1) | Two stat calls |
os.path.sameopenfile(fd1, fd2) |
O(1) | O(1) | |
os.path.samestat(s1, s2) |
O(1) | O(1) | Compares two stat_result objects |
Everything in this table is string work except seven: ismount(), realpath(),
samefile() and sameopenfile() always touch the filesystem; abspath() and
relpath() do so for a relative argument; and expanduser() consults the
password database for a ~user prefix. samestat() only compares two results
that were fetched already.
realpath() is the one worth reading twice. Neither the argument's length nor
the result's bounds its cost: every symlink it resolves splices that link's
target into the path still to be walked, and those components are stat'ed in
turn. A two-component argument can cost more lstat calls than a sixteen-component
argument containing no links, and two arguments that resolve to the same path
can cost differently if one reaches it through a longer chain of links.
normpath() is the string-only alternative when the links do not matter.
Filenames and path objects¶
| Operation | Time | Space | Notes |
|---|---|---|---|
os.fspath(path) |
O(1) | O(1) | Returns str and bytes arguments unchanged; anything else costs whatever its __fspath__ does |
os.PathLike |
O(1) | O(1) | ABC; isinstance() checks for __fspath__ |
os.fsencode(filename) |
O(n) | O(n) | Returns a bytes argument unchanged |
os.fsdecode(filename) |
O(n) | O(n) | Returns a str argument unchanged |
DirEntry¶
os.scandir() yields os.DirEntry objects. Each one keeps the answers it has
already worked out, so the cost is in the first call — except for a stat that
failed, which is retried every time it is asked.
| Operation | Time | Space | Notes |
|---|---|---|---|
DirEntry.name |
O(1) | O(1) | Carried on the entry |
DirEntry.path |
O(1) | O(1) | Built once, then cached |
DirEntry.is_dir() |
O(1) | O(1) | From the directory entry where the filesystem reports a type, else one stat, cached if it succeeds |
DirEntry.is_file() |
O(1) | O(1) | Same |
DirEntry.is_symlink() |
O(1) | O(1) | Same |
DirEntry.is_junction() |
O(1) | O(1) | 3.12+; always false on POSIX |
DirEntry.stat() |
O(1) | O(1) | One stat on POSIX, then cached; on Windows only a reparse point needs the call |
DirEntry.inode() |
O(1) | O(1) | From the directory entry where available, then cached |
os.scandir.close() |
O(1) | O(1) | Releases the directory handle; the with statement calls it |
stat_result¶
Every field is one attribute read on a struct sequence.
| Operation | Time | Space | Notes |
|---|---|---|---|
stat_result.st_mode |
O(1) | O(1) | Type and permission bits |
stat_result.st_ino |
O(1) | O(1) | |
stat_result.st_dev |
O(1) | O(1) | |
stat_result.st_nlink |
O(1) | O(1) | |
stat_result.st_uid |
O(1) | O(1) | |
stat_result.st_gid |
O(1) | O(1) | |
stat_result.st_size |
O(1) | O(1) | Bytes, without reading the file |
stat_result.st_atime |
O(1) | O(1) | Float seconds, derived from the ns field |
stat_result.st_mtime |
O(1) | O(1) | |
stat_result.st_ctime |
O(1) | O(1) | |
stat_result.st_atime_ns |
O(1) | O(1) | Integer nanoseconds, without the float rounding |
stat_result.st_mtime_ns |
O(1) | O(1) | |
stat_result.st_ctime_ns |
O(1) | O(1) | |
stat_result.st_blocks |
O(1) | O(1) | |
stat_result.st_blksize |
O(1) | O(1) | |
stat_result.st_rdev |
O(1) | O(1) | |
stat_result.st_birthtime |
O(1) | O(1) | Where the platform records it |
stat_result.st_birthtime_ns |
O(1) | O(1) | 3.12+ |
stat_result.st_flags |
O(1) | O(1) | BSD and macOS |
stat_result.st_gen |
O(1) | O(1) | BSD |
stat_result.st_rsize |
O(1) | O(1) | macOS |
stat_result.st_creator |
O(1) | O(1) | macOS |
stat_result.st_type |
O(1) | O(1) | macOS |
stat_result.st_fstype |
O(1) | O(1) | Solaris |
stat_result.st_file_attributes |
O(1) | O(1) | Windows |
stat_result.st_reparse_tag |
O(1) | O(1) | Windows |
statvfs_result¶
| Operation | Time | Space | Notes |
|---|---|---|---|
statvfs_result.f_bsize |
O(1) | O(1) | |
statvfs_result.f_frsize |
O(1) | O(1) | |
statvfs_result.f_blocks |
O(1) | O(1) | |
statvfs_result.f_bfree |
O(1) | O(1) | |
statvfs_result.f_bavail |
O(1) | O(1) | |
statvfs_result.f_files |
O(1) | O(1) | |
statvfs_result.f_ffree |
O(1) | O(1) | |
statvfs_result.f_favail |
O(1) | O(1) | |
statvfs_result.f_flag |
O(1) | O(1) | |
statvfs_result.f_namemax |
O(1) | O(1) | |
statvfs_result.f_fsid |
O(1) | O(1) |
terminal_size¶
| Operation | Time | Space | Notes |
|---|---|---|---|
terminal_size.columns |
O(1) | O(1) | |
terminal_size.lines |
O(1) | O(1) |
uname_result¶
| Operation | Time | Space | Notes |
|---|---|---|---|
uname_result.sysname |
O(1) | O(1) | |
uname_result.nodename |
O(1) | O(1) | |
uname_result.release |
O(1) | O(1) | |
uname_result.version |
O(1) | O(1) | |
uname_result.machine |
O(1) | O(1) |
sched_param¶
| Operation | Time | Space | Notes |
|---|---|---|---|
sched_param(priority) |
O(1) | O(1) | |
sched_param.sched_priority |
O(1) | O(1) |
Listing a Directory¶
os.listdir() returns names and nothing else, so deciding what each one is
costs a stat per entry. os.scandir() carries the type the kernel already
reported, so for ordinary files and directories the same loop makes no extra
syscall.
import os
# O(n) time, O(n) space - every name is materialised
files = os.listdir(".")
# ...and then one stat per name to classify it
only_files = [name for name in files if os.path.isfile(name)]
# O(n) time - the iterator holds one entry at a time, type included
with os.scandir(".") as entries:
only_files = [entry.name for entry in entries if entry.is_file()]
The iterator is the O(1) part; the list comprehension above it is the caller's
O(n). The type comes from the directory entry itself where the filesystem
reports one. Two cases still cost a stat: a filesystem that reports no type, and
a symlink, which is_file() has to follow to answer about its target. A stat
that succeeds is cached on the entry, so asking again is free; one that fails —
a broken link, say — is retried on every call.
Walking a Tree¶
import os
# O(n) from 3.12; through 3.11 a deep walk also pays for its depth
for dirpath, dirnames, filenames in os.walk("."):
for filename in filenames:
path = os.path.join(dirpath, filename) # O(L)
# Pruning by mutating dirnames in place skips a whole subtree
for dirpath, dirnames, filenames in os.walk("."):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
Where the depth lands in the peak depends on the version: before 3.12 it is a
suspended generator frame per level, each holding its own path, so a deep enough
tree raises RecursionError rather than finishing; from 3.12 only the queue of
entries waiting to be walked is held, and a wide directory is what fills it.
Pruning is the one change that removes work rather than moving it: assigning
into dirnames in place drops the subtree before the walk descends into it.
Avoiding a Second Stat¶
Each of the stat-based os.path predicates makes its own syscall, so asking two
questions about a file that exists costs two stats. Asking once and handling the
failure costs one.
import os
path = "data.txt"
# Two stat calls: one for exists(), one for getsize()
if os.path.exists(path) and os.path.getsize(path) > 0:
pass
# One stat call, and no window between the check and the use
try:
if os.path.getsize(path) > 0:
pass
except FileNotFoundError:
pass
The second form is also the one that is correct under concurrency: a file can
disappear between exists() and getsize().
When several fields are wanted, take one os.stat() and read them off the
result — each field is an attribute read, not another syscall.
import os
import stat
info = os.stat(".") # O(1), one syscall
size = info.st_size # O(1)
mtime = info.st_mtime_ns # O(1)
is_directory = stat.S_ISDIR(info.st_mode)
Building Paths Without Touching the Disk¶
import os
path = os.path.join("home", "user", "documents", "file.txt")
dirname, filename = os.path.split(path)
# dirname = "home/user/documents"
# filename = "file.txt"
name, ext = os.path.splitext("file.txt")
# name = "file"
# ext = ".txt"
# O(1) on POSIX - only the prefix is inspected
os.path.isabs(path)
# O(L + C) and one getcwd() - C is the working directory it is joined to
absolute = os.path.abspath(path)
os.path.realpath() is the exception: it stats once per component it walks,
including the components of every symlink target it splices in along the way.
It is the wrong tool for normalising a string you never intend to open. Use
normpath() for that.
Environment Variables¶
os.environ is a snapshot taken when os is imported, wrapped so that writes
reach the real process environment too. On POSIX a read is a dict lookup with
an encode of the key and a decode of the value around it, so a long value is not
free; Windows stores str and returns it as it is.
import os
user = os.environ.get("USER", "unknown") # O(k + v) - the value is decoded
os.environ["MY_VAR"] = "value" # O(k + v), plus putenv()'s scan
# O(1) - a view, not a copy; the O(m) is in consuming it
pairs = os.environ.items()
count = sum(1 for _ in pairs) # O(m), plus the text it decodes
os.putenv() changes the process environment without updating os.environ,
so a later os.getenv() will not see it. From 3.14, os.reload_environ()
rebuilds the snapshot, at the cost of the whole environment's text rather than
its variable count.
Creating Directory Trees¶
import os
# O(1) - the parent must already exist
os.mkdir("one")
# O(n·L) in both time and space - n is the components that do not exist yet
os.makedirs("one/two/three/four", exist_ok=True)
# O(n·L) time but O(L) space - it unwinds iteratively
os.removedirs("one/two/three/four")
makedirs() recurses down to the first head that already exists, and each
frame holds its own prefix of the path, which is why the space bound carries
both terms. Only the missing components count: one new directory under a
thousand-deep existing tree is a single frame, where a thousand missing ones
raise RecursionError. removedirs() unwinds the other way, keeping one
prefix live at a time.
Version Notes¶
- Python 3.5+:
os.scandir()andos.DirEntry, which is what lets a listing loop answeris_file()without a stat - Python 3.11+: adds
os.login_tty() - Python 3.12+:
os.walk()drives an explicit stack instead of recursing, so depth stops costing a suspended frame per level — in the peak, and in the time, since results no longer pass up through one frame per level. Addsos.setns(),os.unshare(),os.path.splitroot(),DirEntry.is_junction()andstat_result.st_birthtime_ns - Python 3.13+:
os.path.ismount()lstats the parent directly and keepsrealpath()only as a fallback, so it stops scaling with the parent's depth;os.path.isabs()inspects a three-character prefix on Windows instead of rewriting separators across the whole path. Addsos.process_cpu_count(), the timerfd family,os.posix_openpt(),os.grantpt(),os.unlockpt(),os.ptsname()andos.path.isreserved() - Python 3.14+:
os.readinto()fills a caller-supplied buffer whereos.read()allocates. Addsos.reload_environ()
Platform Differences¶
- POSIX: the fork, exec, pty, scheduling, extended-attribute and supplementary-group families above
- Linux:
os.eventfd(),os.memfd_create(),os.timerfd_create(),os.pidfd_open(),os.getrandom(),os.splice(),os.copy_file_range(),os.setns(),os.unshare() - Windows:
os.startfile(),os.add_dll_directory(), the handle inheritance functions, the drive and volume listings, andos.path.isreserved().os.path.normcase()case-folds here and is O(L), where on POSIX it returns its argument unchanged - BSD and macOS:
os.chflags(),os.lchflags(),os.lchmod(), and the extrastat_resultfields listed above
Related Modules¶
- pathlib - object-oriented paths over the same syscalls
- shutil - recursive copies and removals
- glob - pattern expansion over directory listings
- tempfile - temporary files and directories
- subprocess - the supported way to start a process
- stat - interpreting
st_mode
Best Practices¶
✅ Do:
- Use
os.scandir()overos.listdir()when the loop asks what each entry is - Take one
os.stat()and read several fields off the result - Open or stat and handle the failure, rather than testing first and then acting on the answer
- Prune
os.walk()by assigning intodirnames[:], which skips the subtree instead of filtering its results - Use
os.path.join()andos.fspath()rather than string concatenation
❌ Avoid:
- A predicate per question when one
os.stat()answers all of them os.path.realpath()whereos.path.normpath()will doos.makedirs()on a path with enough missing components to exhaust the recursion limitos.putenv()whenos.environ[key] = valueis what you meant- Materialising
os.listdir()for a directory you only iterate once