Python os.chmod(): A Practical Guide to Unix File Permissions

The first time os.chmod() really mattered to me wasn’t in a tutorial—it was in a late-night deploy where a maintenance script silently failed. The code was correct, the server was healthy, and the logs looked fine. The real issue was embarrassingly simple: the script didn’t have the execute bit set after it was copied into place. On Linux and macOS, “can I run this file?” is often a permissions question before it’s anything else.

os.chmod() is the Python tool for changing file and directory permission bits programmatically. Once you’re automating builds, generating artifacts, shipping CLI tools, writing installers, or managing shared folders, you’ll hit situations where permissions decide whether your system works—or fails in ways that are hard to notice.

I’ll walk you through how permission bits actually behave, how os.chmod(path, mode) maps to what you see in ls -l, how to safely modify permissions without accidentally wiping existing bits, and how to handle edge cases like directories, symlinks, and Windows. Along the way, I’ll show runnable patterns I use in production scripts and CI pipelines.

A working mental model: mode bits are a tiny access policy

On Unix-like systems (Linux, macOS, many containers), each filesystem entry has a set of mode bits that encode permissions and a few special behaviors. When you run chmod in a shell, you’re flipping those bits. When you call os.chmod(), you’re doing the same thing—just from Python.

Think of the mode as three permission “rows” (owner, group, others), and three permission “columns” (read, write, execute):

  • Read (r): can read the file contents (or list a directory)
  • Write (w): can modify the file contents (or create/remove items in a directory)
  • Execute (x): can execute the file (or traverse a directory)

If you’ve seen -rwxr-xr--, that’s exactly these bits rendered.

Two details trip people up:

1) Directories treat bits differently

  • r lets you list names in the directory.
  • x lets you enter/traverse the directory and access items if you already know their names.
  • w lets you create/remove/rename entries (usually paired with x to be useful).

2) os.chmod() sets the mode you pass

This is critical: os.chmod(path, mode) doesn’t “add read permission.” It replaces the permission bits with whatever you provide. If you want to add or remove a bit, you typically read the current mode first, then adjust it.

os.chmod() basics: signature, behavior, and what “mode” means

The core call is:

  • os.chmod(path, mode)

Where:

  • path is a string/bytes/path-like object pointing to a file or directory
  • mode is an integer containing permission bits (often written in octal like 0o644)

On Unix, Python’s stat module defines constants for these bits (and a few special flags):

  • Owner permissions: stat.SIRUSR, stat.SIWUSR, stat.SIXUSR (and stat.SIRWXU)
  • Group permissions: stat.SIRGRP, stat.SIWGRP, stat.SIXGRP (and stat.SIRWXG)
  • Others permissions: stat.SIROTH, stat.SIWOTH, stat.SIXOTH (and stat.SIRWXO)
  • Special bits: stat.SISUID, stat.SISGID, stat.S_ISVTX

Python also provides related APIs you should know:

  • pathlib.Path(path).chmod(mode) (nice ergonomics)
  • os.fchmod(fd, mode) (operate on an open file descriptor)
  • os.chmod(..., follow_symlinks=False) on many Unix builds (don’t follow symlinks)
  • os.stat() / os.lstat() to inspect mode bits

A very important platform note:

  • On Windows, os.chmod() exists, but permission semantics differ. Windows ACLs are not the same as Unix mode bits. In practice, os.chmod() on Windows often only affects the “read-only” attribute in a limited way. If you need real authorization control on Windows, you typically work with ACL tooling rather than chmod-style bits.

Octal vs stat constants: how I choose in real code

You’ll see modes written like 0o644 or built from stat constants. Both are valid; the difference is readability and safety.

Quick reference: common octal modes

These are the ones I use most:

  • 0o600 → private file (owner read/write)
  • 0o644 → typical data file (owner read/write, others read)
  • 0o700 → private directory (owner full)
  • 0o755 → typical directory (owner full, others traverse + read)
  • 0o750 → team directory (owner full, group traverse + read)

I like octal when the intent is a known “standard” permission set.

Building mode from constants

I prefer stat constants when I’m adjusting a specific bit or writing code that reads like a policy.

Here’s a complete, runnable example that creates a file and locks it down to owner-only read/write (0o600):

from future import annotations

import os

import stat

from pathlib import Path

def createprivatetoken_file(path: Path) -> None:

path.parent.mkdir(parents=True, exist_ok=True)

path.writetext("apitoken=replace-me\n", encoding="utf-8")

# Owner read + write only (equivalent to 0o600)

privatemode = stat.SIRUSR | stat.S_IWUSR

os.chmod(path, private_mode)

if name == "main":

token_path = Path(".secrets/runtime/token.env")

createprivatetokenfile(tokenpath)

print(f"Wrote {token_path} with restricted permissions")

When you run this on Linux/macOS, ls -l .secrets/runtime/token.env should show something like -rw-------.

A rule I follow: never hardcode 0o777

If you’re tempted to set 0o777 (world-writable) to “fix” an access problem, pause. In shared machines, containers with mounted volumes, or CI runners, that can turn into a security hole or a weird debugging session later.

If you need collaboration, prefer:

  • group ownership (chgrp) and 0o770/0o775, or
  • per-user directories, or
  • a controlled shared directory with the sticky bit (more on that later)

The most common trap: chmod overwrites, so preserve existing bits

A frequent mistake is treating os.chmod() like an “add permission” API:

# This is WRONG if you mean "add execute".

import os

import stat

os.chmod("scripts/backup.sh", stat.S_IXUSR)

That call sets the mode to “owner execute only” (and removes read/write bits). You probably meant “keep current permissions, add execute for owner.”

Here’s the pattern I recommend: read current mode, mask to permission bits, then modify.

from future import annotations

import os

import stat

from pathlib import Path

def addownerexecute(path: Path) -> None:

st = os.stat(path)

# st.stmode includes file type bits; isolate permissions with SIMODE

currentperms = stat.SIMODE(st.st_mode)

newperms = currentperms | stat.S_IXUSR

os.chmod(path, new_perms)

if name == "main":

script_path = Path("scripts/backup.sh")

scriptpath.parent.mkdir(parents=True, existok=True)

scriptpath.writetext("#!/usr/bin/env sh\necho ‘backup running‘\n", encoding="utf-8")

# Start with a sensible baseline: readable by owner, writable by owner

os.chmod(script_path, 0o600)

addownerexecute(script_path)

print(f"Made {script_path} executable for owner")

That small S_IMODE detail prevents accidental changes to the file type bits and keeps your intent focused.

Traditional vs modern workflow

Here’s how I think about this in 2026-era dev workflows:

Task

Traditional approach

Modern approach I recommend —

— Ensure scripts are executable

Run chmod +x manually

Enforce in build step (Python script) + CI check Fix broken perms after unpacking

Debug on server

Validate and set perms during artifact creation Manage secrets file perms

Hope nobody reads it

Create file + chmod 0o600 immediately

If your team uses containers, devcontainers, or ephemeral CI runners, “manual chmod” doesn’t scale. Automated permission enforcement does.

Directories are special: execute means “can traverse”

For directories, the execute bit is the difference between “I can see the names” and “I can actually access things inside.” This is why you sometimes see confusing scenarios like:

  • You can ls a directory, but cannot cat a file inside.
  • Or you cannot cd into a directory even though you can read it.

A safe recursive permission setter

A real-world need: after generating a build output tree, you want:

  • directories: 0o755
  • files: 0o644

Here’s a complete script that does that, while keeping symlinks untouched by default:

from future import annotations

import os

from pathlib import Path

def settreepermissions(root: Path, *, dirmode: int = 0o755, filemode: int = 0o644) -> None:

for path in root.rglob("*"):

try:

if path.is_symlink():

# Symlinks are tricky; leave them alone unless you have a reason.

continue

if path.is_dir():

os.chmod(path, dir_mode)

elif path.is_file():

os.chmod(path, file_mode)

except PermissionError as exc:

raise PermissionError(f"Failed chmod on {path}: {exc}") from exc

if name == "main":

build_root = Path("dist")

buildroot.mkdir(parents=True, existok=True)

(buildroot / "bin").mkdir(existok=True)

(buildroot / "bin" / "daily-report").writetext("#!/usr/bin/env sh\necho ok\n", encoding="utf-8")

(buildroot / "data").mkdir(existok=True)

(buildroot / "data" / "quarterly.csv").writetext("region,revenue\nNA,100\n", encoding="utf-8")

settreepermissions(build_root)

print("Set directory/file permissions under dist/")

If you need executable files inside bin/, you can extend this by detecting known script paths and adding +x for them.

Performance note

Recursively walking a tree is usually fast enough for build artifacts. On typical SSD-backed CI runners, setting permissions over a few thousand files is often in the “tens to hundreds of milliseconds” range. If you’re touching hundreds of thousands of files (monorepo checkouts, huge caches), it can jump to seconds. In those cases, narrow the scope: only chmod what you generate.

Special bits: setuid, setgid, and sticky (use with care)

Python’s stat module exposes special bits that change runtime behavior:

  • stat.S_ISUID (set-user-ID)
  • stat.S_ISGID (set-group-ID)
  • stat.S_ISVTX (sticky)

These are powerful and easy to misuse, so I’ll be direct:

  • Don’t set SISUID or SISGID in application code unless you truly know why. In many environments they’re restricted, and they can introduce privilege escalation risks if misapplied.

Sticky bit for shared directories

The sticky bit is the one I actually see in “normal” operational setups. A classic example is a shared drop folder where everyone can create files, but only the owner (or root) can delete their own files.

Here’s a script that prepares a shared temp directory:

from future import annotations

import os

import stat

from pathlib import Path

def makeshareddropbox(path: Path) -> None:

path.mkdir(parents=True, exist_ok=True)

# World-writable + sticky: similar to /tmp behavior on many systems

mode = 0o777 | stat.S_ISVTX

os.chmod(path, mode)

if name == "main":

shared_path = Path("shared/dropbox")

makeshareddropbox(shared_path)

print(f"Prepared shared directory: {shared_path}")

On a Unix system, ls -ld shared/dropbox should show a t in the others execute position (something like drwxrwxrwt).

Symlinks, file descriptors, and race conditions: the details that matter

Once you’re writing automation that runs with elevated permissions (or handles untrusted paths), chmod becomes security-sensitive.

Symlinks: do you mean the link or the target?

By default, many Unix chmod operations affect the target of a symlink, not the symlink itself. Python exposes a control in some builds:

  • os.chmod(path, mode, follow_symlinks=False)

If you’re operating on paths that could be influenced by an attacker (think: temp directories, extracted archives, user-controlled workspaces), you should be very cautious about symlink tricks.

A safer pattern is operating on file descriptors when possible:

  • open the file safely
  • call os.fchmod(fd, mode)

That reduces time-of-check/time-of-use issues.

from future import annotations

import os

from pathlib import Path

def chmodopenedfile(path: Path, mode: int) -> None:

# Open first, then chmod via file descriptor.

# This is a safer pattern when you care about races.

with open(path, "rb") as f:

os.fchmod(f.fileno(), mode)

if name == "main":

p = Path("artifacts/release-notes.txt")

p.parent.mkdir(parents=True, exist_ok=True)

p.write_text("Release notes\n", encoding="utf-8")

chmodopenedfile(p, 0o644)

print("Set permissions via fchmod")

umask interaction

If you create files and then immediately call chmod, your final permissions are exactly what you set (subject to filesystem and policy constraints). But if you rely on defaults, umask can silently remove bits at creation time. In CI and containers, umask values vary.

My practice:

  • For secrets: create file, then chmod 0o600 immediately.
  • For generated scripts: write file, then chmod to a known baseline, then add execute.

Filesystems and containers can block permission changes

Some filesystems (or mount options) won’t honor Unix permission bits the way you expect:

  • Windows-mounted volumes in Docker Desktop
  • Certain network filesystems
  • Read-only mounts
  • Corporate endpoint security policies

In those cases, os.chmod() may raise PermissionError or it may succeed but not produce the expected behavior. Treat permissions as part of your deployment environment contract, not just your code.

When you should (and should not) reach for os.chmod()

I like being opinionated here because it saves time.

You should use os.chmod() when:

  • You generate files that must not be world-readable (tokens, credentials, private keys).
  • You ship scripts/binaries and need the execute bit set in a reproducible way.
  • You create directories meant for collaboration with a specific permission policy.
  • You build packaging/install steps where permissions must be correct at install time.

You should avoid os.chmod() when:

  • You’re trying to implement real authorization on Windows (use ACL tooling instead).
  • You don’t control the filesystem semantics (some mounts ignore bits).
  • You’re writing a library function that changes permissions of user data unexpectedly.

If you’re writing a library: prefer returning a clear error that tells the caller what permissions are required, rather than changing their files behind their back.

Common mistakes I see (and how I prevent them)

These are the issues I’ve debugged repeatedly across teams.

Mistake 1: Treating chmod as additive

Fix: read current mode and modify bits.

I like to keep a tiny helper around so I don’t re-derive the same masking logic every time:

from future import annotations

import os

import stat

from pathlib import Path

def chmodaddbits(path: Path, bitstoadd: int) -> None:

current = stat.SIMODE(os.stat(path).stmode)

os.chmod(path, current | bitstoadd)

def chmodremovebits(path: Path, bitstoremove: int) -> None:

current = stat.SIMODE(os.stat(path).stmode)

os.chmod(path, current & ~bitstoremove)

if name == "main":

p = Path("scripts/example.sh")

p.parent.mkdir(parents=True, exist_ok=True)

p.write_text("#!/usr/bin/env sh\necho hi\n", encoding="utf-8")

os.chmod(p, 0o644)

chmodaddbits(p, stat.S_IXUSR)

chmodremovebits(p, stat.S_IWOTH)

The key is that I always treat os.chmod() as “write the whole permissions value,” so I compute that value explicitly.

Mistake 2: Forgetting that directories need x to be usable

Fix: when setting directory permissions, include +x for whoever needs access.

A directory with mode 0o644 is basically a broken door: it lets you list names (r) and maybe change metadata (w), but it doesn’t let you traverse (x), which is the thing most people mean when they say “can access the directory.”

If I’m making a directory intended to be readable, I default to 0o755 (or 0o750 for team-only), not 0o644.

Mistake 3: Confusing “execute” with “I can run it”

Fix: remember that “execute” requires more than the x bit.

Even with +x, execution can fail for other reasons:

  • The script has no shebang (e.g., #!/usr/bin/env python3) and isn’t a binary.
  • The interpreter referenced in the shebang doesn’t exist on the target machine.
  • The filesystem is mounted noexec.
  • The file is a text file with Windows line endings and the shebang is malformed.

When I’m debugging “works locally, fails on server,” I check permissions, then shebang/interpreter, then mount options.

Mistake 4: Setting permissions on the wrong path

Fix: log what you chmod, and validate with a stat read-back.

This sounds obvious, but in real automation you might have multiple directories, symlinks, temp paths, or build staging roots. If I’m writing a script that chmods something important, I usually add a sanity check:

from future import annotations

import os

import stat

from pathlib import Path

def ensuremodeexact(path: Path, expected_mode: int) -> None:

os.chmod(path, expected_mode)

actual = stat.SIMODE(os.stat(path).stmode)

if actual != expected_mode:

raise RuntimeError(f"Expected {oct(expected_mode)} on {path}, got {oct(actual)}")

if name == "main":

p = Path("output/config.ini")

p.parent.mkdir(parents=True, exist_ok=True)

p.write_text("[app]\nname=demo\n", encoding="utf-8")

ensuremodeexact(p, 0o644)

On filesystems that ignore Unix permission bits, this check will save you from a false sense of correctness.

Mistake 5: Wiping special bits accidentally

Fix: if you must preserve special bits, preserve them intentionally.

Most application code shouldn’t touch setuid/setgid/sticky bits at all. But occasionally you’re operating on a directory where S_ISGID is used to enforce group inheritance (common for team-shared directories). If you “set exact mode” without preserving, you might break the intended policy.

If I need to preserve special bits on a directory while normalizing read/write/execute bits, I do it explicitly:

from future import annotations

import os

import stat

from pathlib import Path

def normalizedirpermspreservespecial(path: Path, basedirperms: int) -> None:

st = os.stat(path)

current = st.st_mode

# Special bits we might want to preserve

special = current & (stat.SISUID

stat.SISGID

stat.S_ISVTX)

os.chmod(path, basedirperms | special)

if name == "main":

d = Path("shared/team")

d.mkdir(parents=True, exist_ok=True)

normalizedirpermspreservespecial(d, 0o775)

This is a “sharp tool” pattern; I only use it when I can articulate why those special bits exist.

Inspecting permissions in Python (and mapping to ls -l)

When I’m teaching this, I try to remove the mystery: you should be able to look at a path, print its mode, and reason about it.

Read the current permissions

At minimum, I want these two pieces:

  • the raw st_mode (includes file type)
  • the permission bits only (what chmod manipulates)

from future import annotations

import os

import stat

from pathlib import Path

def describe(path: Path) -> str:

st = os.stat(path)

perms = stat.SIMODE(st.stmode)

return f"path={path} perms={oct(perms)}"

if name == "main":

p = Path("example.txt")

p.write_text("hello\n", encoding="utf-8")

print(describe(p))

oct(perms) gives you something like 0o644, which is usually the fastest mental decode.

Render -rwxr-xr-- style text

If I need a human-friendly string (similar to ls -l), I use stat.filemode():

import os

import stat

from pathlib import Path

def filemode_string(path: Path) -> str:

st = os.stat(path)

return stat.filemode(st.st_mode)

if name == "main":

p = Path("example.txt")

p.write_text("hello\n", encoding="utf-8")

print(filemode_string(p))

That prints something like -rw-r--r--. This is extremely handy when you’re logging permission issues in automation.

Translating between octal and rwx

If you’re new to octal modes, here’s the clean mental trick:

  • Each “row” (owner/group/others) is a 3-bit number.
  • r=4, w=2, x=1.
  • Add them up per row.

Examples:

  • 7 = rwx (4+2+1)
  • 6 = rw- (4+2)
  • 5 = r-x (4+1)
  • 4 = r-- (4)

So 0o755 is:

  • owner 7rwx
  • group 5r-x
  • others 5r-x

And 0o640 is:

  • owner 6rw-
  • group 4r--
  • others 0---

When I’m writing code, I tend to use octal for “known final states” and stat bit masks for “surgical changes.”

Practical scenarios I actually use os.chmod() for

This is the part that turns a reference function into a tool you’ll reach for confidently.

Scenario 1: Make generated scripts executable in a build step

If a build step generates a script file, I don’t rely on git file mode or a developer remembering chmod +x. I set it deliberately.

from future import annotations

import os

import stat

from pathlib import Path

def writeexecutablescript(path: Path, content: str) -> None:

path.parent.mkdir(parents=True, exist_ok=True)

path.write_text(content, encoding="utf-8")

# Start from a conservative baseline then add execute.

os.chmod(path, 0o644)

current = stat.SIMODE(os.stat(path).stmode)

os.chmod(path, current

stat.SIXUSR

stat.SIXGRPstat.S_IXOTH)

if name == "main":

script = Path("dist/bin/hello")

writeexecutablescript(script, "#!/usr/bin/env sh\necho hello\n")

Note what I did there: I made it readable to all (0o644) and executable to all (+111), which ends up effectively 0o755 for most scripts. If that’s too permissive for your environment, choose owner-only execute (stat.S_IXUSR) and keep it 0o744 or 0o700.

Scenario 2: Lock down secrets immediately (and fail loudly)

For secrets, I’m strict. If I can’t set permissions properly, I want to know, because the alternative is “it exists, and might be readable.”

from future import annotations

import os

import stat

from pathlib import Path

def writesecretfile(path: Path, content: str) -> None:

path.parent.mkdir(parents=True, exist_ok=True)

path.write_text(content, encoding="utf-8")

os.chmod(path, 0o600)

actual = stat.SIMODE(os.stat(path).stmode)

if actual != 0o600:

raise RuntimeError(f"Secret file permissions not enforced on {path}: got {oct(actual)}")

if name == "main":

writesecretfile(Path(".secrets/db_password"), "super-secret\n")

If this trips in CI on a weird filesystem, I don’t “paper over” it. I either change where secrets are stored (inside the container filesystem rather than a mounted volume) or I switch to a secret manager.

Scenario 3: Prepare a shared team directory with predictable behavior

If you want a group-shared directory where new files inherit the group, you typically use the setgid bit on the directory and a group-friendly permission mode.

I’ll say it plainly: this is operationally useful, but it’s also an area where your org’s policies (and default umask) matter a lot.

A minimal example just demonstrating the chmod part:

from future import annotations

import os

import stat

from pathlib import Path

def prepareteamdir(path: Path) -> None:

path.mkdir(parents=True, exist_ok=True)

# Group-write + setgid on directory.

# This does not change ownership; you still need correct group ownership.

os.chmod(path, 0o2775)

# Validate: permission bits include setgid.

mode = os.stat(path).st_mode

if not (mode & stat.S_ISGID):

raise RuntimeError(f"Expected setgid bit on {path}")

if name == "main":

prepareteamdir(Path("shared/team"))

I only do this in environments where group ownership is managed intentionally (e.g., on a server with a known group) because otherwise it turns into “works on one machine, confusing on another.”

Archives, packaging, and why permissions often get lost

A lot of real-world permission bugs show up when files move across boundaries:

  • checked out from git on one OS and deployed to another
  • extracted from a .zip
  • copied through a build container onto a mounted volume
  • produced by a tool that doesn’t preserve mode bits

If you’re packaging executables or scripts, you want to decide where permissions are “authoritative.” My preference:

  • During artifact creation, normalize permissions for the artifact’s tree.
  • During install/unpack, re-apply permissions if the archive format or destination can lose them.

Some formats preserve Unix modes better than others. Some pipelines strip them unintentionally. So rather than trusting the universe, I put a small “permission normalization” step at the end of builds.

Error handling: what to do when chmod fails

In production scripts, chmod failures usually fall into a few buckets.

PermissionError

Typically one of:

  • you don’t own the file and aren’t privileged
  • the directory doesn’t allow changes (permissions or ACL)
  • you’re on a read-only filesystem

My rule: if I’m chmodding something I just created, PermissionError is a bug or an environment contract violation, and I fail loudly.

If I’m chmodding existing user-controlled paths (like a tool that “fixes permissions”), I treat it as best-effort and report a summary.

FileNotFoundError

Often indicates a race (another process removed it) or a path mismatch. For build steps, I treat it as fatal because it likely means I’m operating in the wrong place.

“It succeeded but didn’t change anything”

This is the tricky one. Some mounts ignore mode changes. That’s why I like optional “read back and validate” checks for high-stakes files.

Alternatives and complements to os.chmod()

Sometimes chmod is the right tool, sometimes it’s only part of the solution.

Use umask as a default policy (but don’t depend on it)

In long-running processes or controlled environments, a reasonable umask can prevent accidentally creating world-readable files. But I don’t like relying on it for correctness, because:

  • CI runners differ
  • container entrypoints differ
  • developers’ shells differ

So I treat umask as a safety net, not the primary mechanism.

Use ownership and groups for collaboration

If your goal is “only team members can edit these files,” permission bits alone are not enough unless group ownership is correct.

That’s why I think of chmod as “what can the owning user/group/others do,” but chown/chgrp (or admin-managed ownership) is often what makes the policy real.

Use ACLs when the model doesn’t fit

When you need more granular control than owner/group/others (especially on Windows), ACLs are usually the better model.

I won’t go deep into ACL APIs here, but my decision rule is simple:

  • If you can express your policy with owner/group/others and special bits, Unix modes are great.
  • If you need per-user exceptions or complex sharing rules, you’re probably in ACL territory.

Windows notes: what os.chmod() can and can’t do

It’s tempting to write one cross-platform “set permissions” function and call it a day. In practice:

  • On Unix, chmod is fundamental.
  • On Windows, chmod is not the primary permission system.

What I do in cross-platform Python tooling:

  • If the permission is about “secrets shouldn’t be world-readable,” I still try to use os.chmod(path, 0o600) on Windows, but I treat it as “best effort,” and I document that real enforcement depends on ACLs and the environment.
  • If the permission is about “must be executable,” I avoid relying on chmod on Windows entirely. Executability there is not controlled by the Unix execute bit; it’s about file extensions and the shell/loader.

In other words: I write cross-platform code that behaves correctly on Unix, and behaves sensibly (without false promises) on Windows.

A troubleshooting checklist I actually follow

When something fails and I suspect permissions, I walk this order:

1) Confirm the path is what I think it is

– Print the absolute path.

– Check for symlinks (Path.is_symlink()).

2) Log the current mode in two formats

oct(stat.SIMODE(os.stat(p).stmode))

stat.filemode(os.stat(p).st_mode)

3) Check the directory permissions too

– A file can be 644, but if the directory doesn’t have execute for you, you’re blocked.

4) Check mount options / filesystem type

– Especially inside containers or when using mounted volumes.

5) If it’s execution, verify shebang/interpreter and line endings

– Permissions can be perfect and it still won’t run.

6) Validate after chmod when it matters

– Read back the mode bits.

This checklist is boring—which is exactly why I like it.

Putting it together: a small “permission policy” helper

When I’m working on a real project, I tend to centralize permission logic so it’s not scattered across scripts. Here’s a compact helper module pattern:

from future import annotations

import os

import stat

from dataclasses import dataclass

from pathlib import Path

@dataclass(frozen=True)

class PermissionPolicy:

dir_mode: int = 0o755

file_mode: int = 0o644

secret_mode: int = 0o600

def apply_tree(self, root: Path) -> None:

for path in root.rglob("*"):

if path.is_symlink():

continue

if path.is_dir():

os.chmod(path, self.dir_mode)

elif path.is_file():

os.chmod(path, self.file_mode)

def makeexecutable(self, path: Path, *, forall: bool = True) -> None:

current = stat.SIMODE(os.stat(path).stmode)

bits = stat.S_IXUSR

if for_all:

bits

= stat.SIXGRP

stat.SIXOTH

os.chmod(path, current | bits)

def lockdownsecret(self, path: Path) -> None:

os.chmod(path, self.secret_mode)

actual = stat.SIMODE(os.stat(path).stmode)

if actual != self.secret_mode:

raise RuntimeError(f"Secret permissions not applied on {path}: {oct(actual)}")

if name == "main":

policy = PermissionPolicy()

dist = Path("dist")

dist.mkdir(exist_ok=True)

(dist / "bin").mkdir(exist_ok=True)

tool = dist / "bin" / "tool"

tool.write_text("#!/usr/bin/env sh\necho tool\n", encoding="utf-8")

policy.apply_tree(dist)

policy.make_executable(tool)

Centralizing it like this gives me three wins:

  • The modes are defined once (easy to review).
  • Changes are consistent (no “this script uses 777 for some reason”).
  • It becomes testable (even if tests are just “read back the bits”).

Closing thought: permissions are part of correctness

When I write automation now, I treat permissions the same way I treat configuration files, environment variables, and network ports: they’re not “ops magic,” they’re a concrete part of whether the system is correct.

os.chmod() is deceptively simple—just two arguments—but it sits at the boundary between your code and the operating system’s access model. Once you understand that it sets the mode you pass (and that directories and symlinks have sharp edges), it becomes a reliable, boring tool. And boring is exactly what you want from a function that decides whether your scripts run and your secrets stay private.

Scroll to Top