Python File Permissions on Linux: umask, Mode 600, and No Overwrites
On this page
A Python report intended only for you appears with mode 644 after moving to another terminal or automation workflow. The requested creation mode and inherited umask often explain the difference; Python is not changing permissions at random.
This beginner-to-intermediate tutorial reproduces 022 versus 077, then creates a new JSON file without granting group or other-user permissions from the start. It uses dummy ready data, reads no real credentials, and changes no server or scheduler settings.
1. Method: a whole task or one file
- Restrictive defaults for a whole task: set
umask 077in a subshell without changing the current terminal. - Restrict one new file: request
0o600through anopen()opener and usexmode to refuse overwrites. The complete script follows.
Without a directory default ACL, creation permissions follow requested_mode & ~umask, not decimal subtraction. Ordinary text creation usually requests 666: mask 022 produces 644, while 077 produces 600. Mode 600 grants owner read/write, with no group or other-user permissions. It is not encryption. Linux umask manual
Permissions do not replace directory isolation, backups, or access management. Use a trusted directory and trusted parents, with no concurrent path replacement. Do not handle real sensitive data in a shared writable directory.
2. Setup and reproduce the difference
Use Linux and an existing Python 3.9 or newer installation; no packages are required. Commands work in bash or zsh and were tested on Linux with Python 3.11.4. Keep the same terminal and working directory throughout; choose a new directory name when repeating the experiment.
- Show the version and current shell mask. Running
umaskalone only displays it:
python3 --version
mkdir umask-demo
cd umask-demo
umask
- Create files in two subshells, then inspect their actual modes:
(
umask 022
python3 -c "with open('ordinary-022.txt', 'x', encoding='utf-8') as f: f.write('demo\n')"
)
(
umask 077
python3 -c "with open('ordinary-077.txt', 'x', encoding='utf-8') as f: f.write('demo\n')"
)
python3 - <<'PY_MODES'
from pathlib import Path
import stat
for name in ("ordinary-022.txt", "ordinary-077.txt"):
mode = stat.S_IMODE(Path(name).stat().st_mode)
print(f"{name}: {mode:03o}")
PY_MODES
Expected output in an ordinary local directory without a default ACL:
ordinary-022.txt: 644
ordinary-077.txt: 600
The settings inside parentheses end with each subshell. If results differ, inspect the parent’s default ACL and filesystem behavior instead of running chmod -R on the directory. Default ACLs change creation-mode calculation; see the Linux manual linked above.
3. Complete implementation: create, never overwrite
Save this as write_private.py in the current directory:
"""Create a new private demo JSON file on Linux. Python 3.9+, standard library."""
import argparse
import json
import os
from pathlib import Path
import stat
import sys
def private_opener(path, flags):
return os.open(path, flags, 0o600)
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("output", type=Path, help="new file in a trusted directory")
args = parser.parse_args()
try:
with open(args.output, "x", encoding="utf-8", newline="\n",
opener=private_opener) as handle:
json.dump({"status": "ready"}, handle)
handle.write("\n")
mode = stat.S_IMODE(os.fstat(handle.fileno()).st_mode)
except FileExistsError:
print("ERROR: output already exists; not changed", file=sys.stderr)
return 1
except OSError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(json.dumps({"file": str(args.output), "mode": f"{mode:03o}"}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
The open() opener parameter receives a path and flags and returns a file descriptor. This opener preserves the flags supplied by x mode and sets the underlying os.open() creation mode to 0o600. A mask can remove additional owner permissions, so the result is 600 or stricter, not a forced guarantee of owner read/write access.
POSIX exclusive creation refuses existing paths, including a symlink at the final component. It does not protect replaceable parent directories. Linux open manual
The script never calls os.umask(), avoiding a temporary process-wide creation-policy change. If the directory cannot be written at all, start with the directory write-permission probe. Restricting creation mode cannot make an unwritable directory writable.
4. Usage: request private permissions even with a permissive mask
- Show help and run in a subshell using
umask 000. Repeat the same path to verify refusal:
python3 write_private.py --help
(
umask 000
python3 write_private.py private.json
)
python3 -m json.tool private.json
if python3 write_private.py private.json; then
printf 'Unexpected success\n'
else
printf 'Existing file refused\n'
fi
The first invocation writes this to stdout:
{"file": "private.json", "mode": "600"}
json.tool should display the formatted equivalent of {"status": "ready"}. The second invocation writes ERROR: output already exists; not changed to stderr, followed by the shell’s Existing file refused message.
- Handle exit codes: 0 means creation, writing, and closing succeeded; 1 means an existing path or another file-operation failure; 2 means invalid command arguments. Only success writes one JSON line containing path and mode to stdout, never the file’s contents.
This is not atomic publication. A write or close failure can leave an empty or incomplete newly created file. Handle nonzero exit codes rather than treating existence as success. To update existing output, continue with atomic JSON saves and explicitly decide the replacement file’s permissions.
5. Validation: masks and overwrite-refusal branches
This self-check runs in a separate temporary subdirectory and leaves the earlier files alone. Its umask= argument sets only the child process; see the Python subprocess documentation.
python3 - <<'PY_CHECK'
import json
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
script = Path("write_private.py").resolve()
with tempfile.TemporaryDirectory(prefix="mode-check-", dir=".") as folder:
root = Path(folder)
def run(path, mask=0o022):
return subprocess.run(
[sys.executable, str(script), str(path)],
umask=mask, capture_output=True, text=True, check=False,
)
for mask, expected in [(0o000, 0o600), (0o022, 0o600),
(0o077, 0o600), (0o277, 0o400)]:
path = root / f"private-{mask:03o}.json"
result = run(path, mask)
assert result.returncode == 0 and not result.stderr, result
assert stat.S_IMODE(path.stat().st_mode) == expected
assert json.loads(result.stdout)["mode"] == f"{expected:03o}"
assert json.loads(path.read_text()) == {"status": "ready"}
existing = root / "existing.json"
existing.write_bytes(b"keep\n")
existing.chmod(0o644)
link = root / "link.json"
link.symlink_to(existing.name)
broken = root / "broken.json"
broken.symlink_to("absent.json")
for path in (existing, link, broken, root, root / "missing" / "file.json"):
result = run(path)
assert result.returncode == 1 and result.stdout == "", result
assert result.stderr.startswith("ERROR:"), result
assert existing.read_bytes() == b"keep\n"
assert stat.S_IMODE(existing.stat().st_mode) == 0o644
assert link.is_symlink() and broken.is_symlink()
assert not (root / "absent.json").exists()
result = subprocess.run([sys.executable, str(script)],
capture_output=True, text=True, check=False)
assert result.returncode == 2 and not result.stdout and result.stderr
print("OK: masks, JSON, existing file, symlinks, directory, missing parent, arguments")
PY_CHECK
Expected output:
OK: masks, JSON, existing file, symlinks, directory, missing parent, arguments
The test checks both contents and modes, including preservation of an existing file’s bytes and 644 mode. Mask 277 also removes owner write permission, producing 400. The write descriptor opened during creation can still complete that write; subsequent opens are subject to file permissions. The temporary directory is cleaned on exit; see automatic temporary-directory cleanup for more examples.
6. Troubleshooting: do not hide problems by widening permissions
| Symptom | Concrete action |
|---|---|
An old file stays 644 after switching to 077 |
The mask affects creation only. Check ownership and sharing requirements, then restrict that specific file rather than recursively changing a directory tree. |
output already exists |
Choose a new output name or design explicit replacement after reviewing the old result. Do not delete an old file just to pass a check. |
Permission denied |
Check the executing account, parent-directory access, and actual file mode using the earlier directory-probe tutorial. Do not jump to 777. |
No such file or directory |
The script does not create parents. Create an output directory in a trusted location, then pass the complete path. |
| Results differ from the mode table | Inspect default ACLs, mounts, and filesystem permission support. Reproduce in an ordinary local demo directory first. |
| A web server cannot read the output | Private files and public Hugo assets need different policies. Grant only necessary read access to intentionally public output; keep sensitive files outside the website directory. |
Demonstrate repairing an old mode only on the demo file created here:
python3 - <<'PY_FIX'
from pathlib import Path
import stat
path = Path("ordinary-022.txt")
path.chmod(0o600)
print(f"{path.name}: {stat.S_IMODE(path.stat().st_mode):03o}")
PY_FIX
Expect ordinary-022.txt: 600. This change is not controlled by the creation mask. Restricting real data later cannot revoke copies or reads that already happened while its permissions were broader.
7. Summary
Use a subshell’s umask 077 for restrictive defaults across a private task, or request 0o600 during exclusive creation for one output. Verify creation modes, overwrite refusal, and exit codes before adopting the pattern. Correct permissions do not prove the file’s contents are complete.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-umask-private-files.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Merge Sorted Logs by Timestamp in Python with heapq.merge
- Read the Last N Log Lines in Python with deque
- Missing Hugo Posts: Diagnose Drafts, Publication Dates, Expiry, and Output Paths
- Fixed-Interval Python Tasks with monotonic: Avoid Drift and Skip Missed Slots
- Build URL Query Parameters in Python: Unicode, Plus Signs, and Repeated Keys