Managing files safely is not about new APIs — it is about applying the ones you already know with care. "Safe" means three things: files are always closed, writes are either complete or not visible at all, and errors are reported where they happen instead of silently corrupting state. Python gives you the pieces (with, try/except, atomic rename, pathlib); this lesson assembles them into patterns you can drop into real code.
The first pillar is the with statement. Wrapping every open() in with guarantees the file is closed, even if your code raises halfway through. Nested with blocks (or comma-separated resources inside a single with) handle source/destination pairs. This one habit prevents the entire class of "file still locked because no one called close" bugs, especially on Windows.
The second pillar is atomic replace. Write your new data to a temp file next to the target, flush, fsync if durability matters, and then call Path(tmp).replace(target). On every major OS, replace is an atomic operation: readers of the target path never see a half-written file. This is how well-behaved programs update configuration files without risking corruption.
The third pillar is defensive error handling. FileNotFoundError, PermissionError, IsADirectoryError, and OSError are the exceptions you will see most. Catch the narrow ones, not the bare Exception. When the operation is dangerous (deletion, overwriting), check the preconditions you care about first (existence, size, checksum) and let unexpected states bubble up.
Safe open, safe read, safe write
Always: with open(path, mode, encoding="utf-8") as f:. Always: catch narrow exceptions at the boundary where you can do something useful ("config missing, use defaults"). Never: leave a bare open() without with in production code.
For backups of overwriting writes, copy the target to target.with_suffix(".bak") before you start. shutil.copy2 preserves metadata; Path(old).rename(new) is instant on the same filesystem.
Atomic rename in practice
The idiom is a three-liner: write the bytes into a temp file, call os.fsync(f.fileno()) while the file is open, and tmp.replace(target) after the with block exits. This pattern is how pip, git and most databases update single files safely.
For operations that touch multiple files, group them into a transactional step yourself: keep a manifest of what was changed, and on failure, restore from it. Real databases do this for you; simple scripts should at least back up before destructive operations.
The safety net for file operations.
| Tool | Purpose |
|---|---|
with open(...) as fstatement | Ensures file is closed even on error. |
Path.replace(target)method | Atomic rename; no partial file visible. |
os.fsync(fd)function | Forces data to stable storage. |
shutil.copy2(src, dst)function | Copies file plus metadata. |
FileNotFoundErrorexception | Raised when the file does not exist. |
PermissionErrorexception | Raised when the OS denies access. |
tempfile.NamedTemporaryFileclass | Creates a temp file with automatic cleanup. |
Path.exists()method | Safe existence probe before acting. |
Managing Files Safely code example
The script below updates a config file using the atomic-rename pattern and handles missing-file errors narrowly.
# Lesson: Managing Files Safely
import os
from pathlib import Path
from tempfile import gettempdir
def atomic_write_text(target: Path, data: str) -> None:
"""Write data to target atomically: temp file + fsync + replace."""
tmp = target.with_suffix(target.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
tmp.replace(target)
def read_config(path: Path) -> dict:
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return {} # missing = defaults
return dict(line.split("=", 1) for line in text.splitlines() if line.strip())
root = Path(gettempdir())
config = root / "app.conf"
# First run: defaults, file does not exist yet
print("before:", read_config(config))
# Atomic write
atomic_write_text(config, "host=localhost\nport=5432\n")
print("after write:", read_config(config))
# Atomic update (no half-written state ever visible)
atomic_write_text(config, "host=db.internal\nport=5432\n")
print("after update:", read_config(config))
# Narrow exception handling
try:
(root / "no-such-file.txt").read_text(encoding="utf-8")
except FileNotFoundError as err:
print("handled:", err)
config.unlink()
Compare the unsafe and safe versions in your head:
1) atomic_write_text never leaves a partially written target visible.
2) `fsync` is the extra durability step: the OS promises the bytes hit disk.
3) `FileNotFoundError` is handled specifically; other errors bubble up.
4) `read_config` tolerates a missing file by returning an empty dict.
Practice a safe delete and a safe backup.
from pathlib import Path
from shutil import copy2
from tempfile import gettempdir
p = Path(gettempdir()) / "mine.txt"
p.write_text("hello", encoding="utf-8")
# Example A: back up before overwriting
backup = p.with_suffix(".txt.bak")
copy2(p, backup)
p.write_text("HELLO", encoding="utf-8")
print("current:", p.read_text(encoding="utf-8"))
print("backup :", backup.read_text(encoding="utf-8"))
# Example B: safe delete: only remove if it exists
for f in (p, backup):
if f.exists():
f.unlink()
Logic-only checks.
from pathlib import Path
assert Path("x.txt").with_suffix(".bak").suffix == ".bak"
assert isinstance(FileNotFoundError(), OSError)
assert isinstance(PermissionError(), OSError)
text = "a\nb\nc"; assert len(text.splitlines()) == 3
Running prints roughly:
before: {}
after write: {'host': 'localhost', 'port': '5432'}
after update: {'host': 'db.internal', 'port': '5432'}
handled: [Errno 2] No such file or directory: '/tmp/no-such-file.txt'