Files and Context Managers
Reading and writing files, and the `with` statement that guarantees cleanup — the single most idiomatic construct in Python.
with open("notes.txt", "r", encoding="utf-8") as f:
content = f.read()
# the file is closed here — even if an exception was raisedThe with statement is the point. It guarantees the file is closed on every path out of the block, including an exception, which is why you should essentially never call open() without it.
Modes#
| Mode | Meaning |
|---|---|
"r" | read (default); error if the file is missing |
"w" | write; truncates an existing file |
"a" | append |
"x" | create; error if it already exists |
"rb" / "wb" | binary — no decoding, gives you bytes |
"r+" | read and write |
"x" is underused: it is the mode that refuses to overwrite, which is what you want for anything you are creating for the first time.
Reading#
with open("data.txt", encoding="utf-8") as f:
content = f.read() # the whole file as one string
with open("data.txt", encoding="utf-8") as f:
for line in f: # one line at a time — lazy, no size limit
print(line.rstrip("\n"))Iterate the file object rather than calling .readlines(). Iteration streams; readlines() loads everything into memory, which is fine for a config file and fatal for an 8GB log.
with open("big.log", encoding="utf-8") as f:
errors = sum(1 for line in f if "ERROR" in line) # constant memoryWriting#
with open("out.txt", "w", encoding="utf-8") as f:
f.write("first line\n") # write does NOT add a newline
f.writelines(f"{n}\n" for n in range(3))
print("via print", file=f) # print does add onepathlib#
The modern way to handle paths. Prefer it to os.path string juggling:
from pathlib import Path
config = Path("config") / "settings.json" # / joins, cross-platform
config.exists()
config.suffix # ".json"
config.stem # "settings"
config.parent # Path("config")
text = config.read_text(encoding="utf-8") # open, read, close
config.write_text(data, encoding="utf-8")
raw = config.read_bytes()
Path("output").mkdir(parents=True, exist_ok=True)
for py in Path("src").rglob("*.py"): # recursive glob
print(py)read_text and write_text handle the open and close for you, which makes with unnecessary for small whole-file operations.
Writing safely#
A crash partway through a "w" write leaves a truncated file. Write to a temporary file and rename:
import os, tempfile
from pathlib import Path
def write_atomic(path: Path, data: str) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno()) # actually on disk
os.replace(tmp, path) # atomic within a filesystem
except BaseException:
os.unlink(tmp)
raiseos.replace is atomic, so a reader sees either the old file or the new one — never a half-written one.
What with actually does#
Any object implementing __enter__ and __exit__ works with with. It is a general resource-management protocol, not a file feature:
with open("a.txt") as f: ... # closes the file
with lock: ... # releases the lock
with conn.transaction(): ... # commits or rolls back
with tempfile.TemporaryDirectory() as d: ... # removes the directorySeveral at once:
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())Writing your own#
The decorator form covers almost every case:
from contextlib import contextmanager
import time
@contextmanager
def timed(label: str):
start = time.perf_counter()
try:
yield # the body of the `with` runs here
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")
with timed("import"):
process_everything()Everything before yield is setup, everything after is cleanup, and finally guarantees the cleanup runs even when the body raises. That try/finally is the part people forget, and without it the context manager silently stops cleaning up on the exact path where cleanup matters most.
The class form, when you need state:
class Connection:
def __enter__(self):
self.conn = connect()
return self.conn # what `as` binds
def __exit__(self, exc_type, exc, tb):
self.conn.close()
return False # False: do not suppress the exceptionReturning True from __exit__ swallows the exception. Almost always wrong, and worth knowing so you never do it by accident.
Exercise#
from pathlib import Path
# 1. Write "report.txt" containing three lines, using pathlib.
# 2. Read it back line by line and print each with its 1-based line number.
# 3. Write a `counted(label)` context manager that prints how many lines
# were written inside the block.
# Expected output:
# 1: alpha
# 2: beta
# 3: gammaCommon questions#
Do I still need with if I call close() myself?#
Yes. An exception between open() and close() skips your close() entirely, leaking the descriptor. with closes on every exit path, which is why it is the idiom rather than a convenience.
pathlib or os.path?#
pathlib for new code — the / operator, read_text, rglob and the property accessors are clearer than string manipulation. os.path remains everywhere in existing code and in some library APIs, and the two interoperate fine.
Why does my file have wrong characters on another machine?#
You almost certainly omitted encoding="utf-8", so Python used the platform default, which differs between systems. Always pass it explicitly for text files.
Get the Python agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Python. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.