16.1 File Fundamentals
Why file handling. A variable lives only in the running process's memory (RAM) — the instant the program ends (or crashes), everything it held is gone. File handling is how a program persists data beyond its own lifetime, by asking the operating system to read and write it to disk instead:
| Storage | Lifetime | Example |
|---|---|---|
| In-memory (a variable) | only while the program is running | a list, a dict |
| Temporary storage | until the OS/user cleans it up; often explicit | the system temp directory, a cache |
| Permanent storage (a file) | survives the program ending and the machine rebooting | a .txt, .csv, .json file on disk |
Python's built-in open() is the entry point for all of this — it hands back a
file object connected to a real file on disk, through which the program reads or
writes data.
Text vs. binary — every file is really just a sequence of bytes on disk; the difference is what those bytes are interpreted as:
| Kind | Interpreted as | Typical use |
|---|---|---|
| Text | characters, via a text encoding (default: UTF-8) | .txt, .py, .csv, .json — human-readable |
| Binary | raw bytes, no encoding/decoding at all | images, executables, pickled data |
Encoding is exactly the boundary between the two — a str only becomes bytes (what
actually gets written to disk) by going through one:
message = "hi"
print(message) # hi -> just a str in memory
print(message.encode("utf-8")) # b'hi' -> the actual bytes that would be written to disk
hi b'hi'
16.2 Text File Open Modes
open(path, mode)'s second argument controls both what is allowed (read/write) and
where it starts:
| Mode | Meaning |
|---|---|
"r" |
read an existing file (the default if mode is omitted); error if missing |
"w" |
write — creates the file if missing, truncates (erases) it if it exists |
"a" |
append — creates the file if missing, writes are added after existing content |
"x" |
exclusive creation — creates a new file; errors if it already exists |
"r+" |
read and write on an existing file, without truncating it |
"w+" |
write and read, but truncates first, exactly like "w" |
"a+" |
append and read |
A trailing "t" (text) is the implicit default for every mode above — "rt" means
exactly the same thing as "r". Reading, writing, and appending, in order:
The examples below use tempfile.mkdtemp() to create a fresh temporary directory, so
running this notebook doesn't scatter files around your filesystem; in your own code
you'd normally pass a plain path (e.g. "notes.txt" or "/home/you/notes.txt")
instead:
import tempfile, os
work_dir = tempfile.mkdtemp()
path = os.path.join(work_dir, "notes.txt")
f = open(path, "w") # 'w' - create (or truncate) for writing
f.write("first line\n")
f.close()
f = open(path, "r") # 'r' - read an existing file (the default mode)
print(f.read()) # first line
f.close()
f = open(path, "a") # 'a' - append, creating the file if it doesn't exist
f.write("second line\n")
f.close()
f = open(path, "r")
print(f.read()) # first line \n second line
f.close()
first line first line second line
In practice — every config-reading or report-writing script does this. A DevOps
script reading a YAML/.env-style config file, or a nightly job writing out a report
file, uses exactly this open/read-or-write/close shape — the only real question in
practice is almost always which mode ("r", "w", "a") fits the task, since the
mechanics are identical across every one of them.
"x" refuses to clobber an existing file, and "r+" writes into an existing file
without erasing what was already there:
try:
open(path, "x") # 'x' - exclusive creation, fails if the file already exists
except FileExistsError as e:
print("FileExistsError:", e) # [Errno 17] File exists: '...notes.txt'
f = open(path, "r+") # 'r+' - read AND write on an existing file, no truncation
f.write("XXXX") # overwrites starting at position 0, doesn't erase the rest
f.close()
f = open(path, "r")
print(f.read()) # XXXXt line \n second line \n -> only the first 4 characters were overwritten
f.close()
FileExistsError: [Errno 17] File exists: '/tmp/tmpc0oemt3i/notes.txt' XXXXt line second line
"w+" combines writing with reading back, and "rt" demonstrates the implicit "t":
scratch_path = os.path.join(work_dir, "scratch.txt")
f = open(scratch_path, "w+") # 'w+' - write AND read, but truncates the file first
f.write("hello")
f.seek(0) # move back to the start to read what was just written
print(f.read()) # hello
f.close()
f = open(scratch_path, "rt") # 'rt' - the same as plain "r"; 't' (text) is the implicit default
print(f.read()) # hello
f.close()
hello hello
16.3 Binary File Open Modes
Every text mode from 16.2 has a binary counterpart — just append "b" instead of the
implicit "t", and reads/writes deal in raw bytes instead of str:
| Mode | One-liner |
|---|---|
"rb" |
read raw bytes from an existing file |
"wb" |
write raw bytes, creating/truncating the file |
"ab" |
append raw bytes to the end of the file |
"xb" |
exclusive creation, in binary |
"rb+" |
read and write raw bytes on an existing file, no truncation |
"wb+" |
write and read raw bytes, truncating first |
"ab+" |
append and read raw bytes |
bin_path = os.path.join(work_dir, "data.bin")
f = open(bin_path, "wb") # 'wb' - write raw bytes, no text encoding involved
f.write(bytes([72, 105, 33])) # writes those exact byte values directly
f.close()
f = open(bin_path, "rb") # 'rb' - read raw bytes back
print(f.read()) # b'Hi!'
f.close()
b'Hi!'
16.4 The File Object
open() returns a file object carrying a handful of properties about how it was
opened, plus state-query methods for what it currently allows:
| Property / method | Gives |
|---|---|
.name |
the path (or descriptor) it was opened with |
.mode |
the mode string it was opened with |
.encoding |
the text encoding in use (text mode only) |
.closed |
True once .close() has run, False while still open |
.writable() |
whether writing is currently allowed |
.readable() |
whether reading is currently allowed |
.write(s) sends s to the file and returns how many characters were written:
profile_path = os.path.join(work_dir, "profile.txt")
f = open(profile_path, "w", encoding="utf-8")
print(f.name == profile_path) # True -> the exact path it was opened with
print(f.mode) # w
print(f.encoding) # utf-8
print(f.closed) # False -> still open
print(f.writable()) # True
print(f.readable()) # False -> opened write-only
n = f.write("Ada Lovelace\n") # write() returns the number of characters written
print(n) # 13
f.close()
print(f.closed) # True -> now closed
True w utf-8 False True False 13 True
Giving open() a path. A relative path ("notes.txt") is resolved against the
current working directory — wherever the process happens to be running from, not
where the .py file lives — while an absolute path always names the same file
regardless of the working directory. Building a path by hand with + and "/" breaks
on Windows, whose separator is "\\" — os.path.join() (or pathlib's / operator,
9.6) picks whichever separator the running OS actually needs (1.1.5's platform
independence, applied to paths):
from pathlib import Path
joined = os.path.join(work_dir, "sub", "notes.txt") # portable - right separator for the OS
pathlib_version = Path(work_dir) / "sub" / "notes.txt" # pathlib's / operator does the same job
print(str(pathlib_version) == joined) # True -> two ways to build the identical path
relative = "relative_notes.txt" # resolved against the CURRENT WORKING DIRECTORY, not this file's location
print(os.path.abspath(relative) == os.path.join(os.getcwd(), relative)) # True
True True
In practice — deployment scripts that run on more than one OS. A DevOps script
built and tested on a developer's Mac/Windows machine but deployed to a Linux
production server will silently break if it hardcodes a path with "/" or "\\" —
os.path.join()/pathlib.Path are what make the exact same script work unmodified in
both places.
Going deeper. For new code, prefer pathlib.Path over os.path — it wraps a path
in an object with useful methods (.exists(), .suffix, .read_text(), ...) instead
of loose functions that take and return plain strings. os.path is shown here because
it's still extremely common in existing codebases, and reading it fluently matters even
when it isn't your first choice for new work.
16.5 Reading Character Data from Text Files
Four ways to pull characters back out of a text file, from least to most structured:
| Call | Reads |
|---|---|
f.read(n) |
exactly n characters (or fewer, if the file ends first) |
f.read() |
everything left in the file, as one str |
f.readline() |
one line at a time, including its trailing \n; "" once nothing's left |
f.readlines() |
every remaining line, all at once, as a list of strings |
poem_path = os.path.join(work_dir, "poem.txt")
with open(poem_path, "w") as f:
f.write("roses are red\nviolets are blue\n")
f = open(poem_path, "r")
print(f.read(5)) # roses -> read(n) reads exactly n CHARACTERS
print(f.read()) # ' are red\nviolets are blue\n' -> read() with no args reads the REST
f.close()
f = open(poem_path, "r")
print(f.readline()) # roses are red\n -> ONE line, including the trailing newline
print(f.readline()) # violets are blue\n
print(f.readline()) # "" -> empty string once there's nothing left
f.close()
f = open(poem_path, "r")
print(f.readlines()) # ['roses are red\n', 'violets are blue\n']
f.close()
roses are red violets are blue roses are red violets are blue ['roses are red\n', 'violets are blue\n']
The idiomatic way to process a file line by line is to iterate over the file object
directly — it's itself a lazy iterator (14.1, 14.2) that yields one line per next()
call, instead of loading every line into memory at once like readlines() does:
f = open(poem_path, "r")
for line in f: # one line at a time, lazily
print(line.strip())
f.close()
# roses are red
# violets are blue
roses are red violets are blue
16.6 The with Statement
An open file holds an OS-level resource (a file descriptor) that needs to be released
with .close() — but if an exception happens between open() and .close(), that
.close() call is skipped entirely, leaking the resource. The concrete failure story:
a program that opens many files without ever closing them eventually hits the OS's
open-file-descriptor limit and crashes with OSError: [Errno 24] Too many open files.
with open(...) as f: fixes this structurally: open()'s return value is a context
manager — it implements __enter__/__exit__ (13.1) — and with guarantees
__exit__ (which closes the file) runs when the block ends, no matter how it ends:
log_path = os.path.join(work_dir, "log.txt")
with open(log_path, "w") as f: # calls f.__enter__() on entry, f.__exit__() on exit
f.write("with a context manager")
print(f.closed) # False -> still open INSIDE the block
print(f.closed) # True -> closed automatically the instant the block ends
try:
with open(log_path, "w") as f:
f.write("partial")
raise ValueError("something went wrong")
except ValueError:
print(f.closed) # True -> __exit__ ran and closed the file EVEN THOUGH an exception was raised
False True True
Writing your own context manager
with isn't limited to files — any object implementing __enter__(self) and
__exit__(self, exc_type, exc_val, exc_tb) (13.1) can follow with. __enter__ runs
on entry, and its return value becomes whatever as binds to. __exit__ always runs on
exit, whether the block finished normally or raised an exception, and it receives that
exception's type, value, and traceback as its three arguments (all None if nothing
went wrong). Returning True from __exit__ tells with to suppress the exception
instead of letting it propagate; returning None/False (the usual case) lets it
propagate normally after cleanup runs:
class ManagedFile:
def __init__(self, path, mode):
self.path = path
self.mode = mode
def __enter__(self):
self.file = open(self.path, self.mode)
return self.file # this becomes "f" in "with ManagedFile(...) as f:"
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
return False # False/None - don't suppress any exception that occurred
with ManagedFile(log_path, "w") as f:
f.write("hand-written context manager")
print(f.closed) # True -> __exit__ closed it, exactly like the built-in file's does
True
The class above is boilerplate for something conceptually simple: "run this setup code,
then always run that cleanup code." contextlib.contextmanager turns a generator
function into a context manager without writing a class at all — everything before a
single yield runs as __enter__, the yielded value becomes what as binds to, and
everything after yield runs as __exit__ (wrapped in try/finally so the cleanup
still runs if the with block raises). This is the same send/close/yield from
machinery from 14.6, repurposed to drive a with block instead of being called by hand:
from contextlib import contextmanager
@contextmanager
def managed_file(path, mode):
f = open(path, mode)
try:
yield f # this becomes "f" in "with managed_file(...) as f:"
finally:
f.close() # always runs when the block ends, even on an exception
with managed_file(log_path, "w") as f:
f.write("contextlib.contextmanager version")
print(f.closed) # True -> closed automatically, same guarantee as the built-in with
True
In practice — wrapping a database connection or a distributed lock. Production
code commonly wraps things far more consequential than a file in exactly this pattern:
a context manager that acquires a database connection or a distributed lock (e.g. via
Redis) on entry and guarantees it's released on exit — even if the code inside the
with block raises — so a crash never leaves a lock held or a connection leaked.
16.7 Other Structured File Formats
CSV, JSON, Excel spreadsheets, and PDFs all have real internal structure — rows and
columns, sheets, pages — that plain .read()/.write() (16.5) knows nothing about; it
only sees characters or bytes. Each format instead gets its own dedicated module that
understands that structure:
| Format | Structure | Python module | Built-in? |
|---|---|---|---|
| CSV | plain text; rows of delimiter-separated values | csv |
yes (stdlib) |
| JSON | plain text; nested key-value/array structure | json (9.6) |
yes (stdlib) |
Excel (.xlsx) |
a zip archive of XML; sheets, cells, formulas, styles | openpyxl |
no — pip install openpyxl (9.7) |
| binary; pages of positioned text, fonts, images | pypdf, pdfplumber |
no — pip install pypdf (9.7) |
csv is the simplest and ships with Python — csv.writer/csv.reader work
row-by-row as lists, and csv.DictReader reads each row as a dict keyed by the header
row instead:
import csv
csv_path = os.path.join(work_dir, "people.csv")
with open(csv_path, "w", newline="") as f: # newline="" - stops csv from adding blank lines itself
writer = csv.writer(f)
writer.writerow(["name", "age"]) # header row
writer.writerow(["Ada", 28])
writer.writerow(["Grace", 34])
with open(csv_path, "r", newline="") as f:
for row in csv.reader(f):
print(row)
# ['name', 'age']
# ['Ada', '28']
# ['Grace', '34']
with open(csv_path, "r", newline="") as f:
for row in csv.DictReader(f): # each row as a dict, keyed by the header
print(row)
# {'name': 'Ada', 'age': '28'}
# {'name': 'Grace', 'age': '34'}
['name', 'age']
['Ada', '28']
['Grace', '34']
{'name': 'Ada', 'age': '28'}
{'name': 'Grace', 'age': '34'}json ships with Python too (9.6 introduced the json module for its data-structure
side; here's the file-handling side) and mirrors csv's shape: json.dump(obj, f)
writes a Python object out to an open file as JSON text, and json.load(f) reads that
text back into Python objects:
import json
json_path = os.path.join(work_dir, "people.json")
people = [{"name": "Ada", "age": 28}, {"name": "Grace", "age": 34}]
with open(json_path, "w") as f:
json.dump(people, f) # writes the Python object out as JSON text
with open(json_path, "r") as f:
loaded = json.load(f) # reads the JSON text back into Python objects
print(loaded) # [{'name': 'Ada', 'age': 28}, {'name': 'Grace', 'age': 34}]
print(loaded == people) # True -> round-tripped exactly
[{'name': 'Ada', 'age': 28}, {'name': 'Grace', 'age': 34}]
TrueIn practice — JSON is the default format for config and API payloads. Application
config files (config.json), the request/response bodies of virtually every REST API,
and data exchanged between microservices are almost always JSON — json.dump()/
json.load() (or the .model_dump_json()/.model_validate_json() pair from Pydantic,
20.3/20.5) are two of the most frequently used file-adjacent calls in real Python
services.
Excel and PDF are both binary formats with no standard-library reader — the usual
choice is a third-party package (pip install, 9.7). Their shape (once installed) —
illustrative only: not run in this notebook, since no third-party dependency is
installed here:
# Excel (.xlsx) - openpyxl
import openpyxl
wb = openpyxl.load_workbook("data.xlsx") # a Workbook: one or more Sheets
sheet = wb.active # or wb["SheetName"]
print(sheet["A1"].value) # read one cell
sheet["B2"] = "new value" # write one cell
wb.save("data.xlsx")
# PDF - pypdf
from pypdf import PdfReader
reader = PdfReader("document.pdf")
print(len(reader.pages)) # number of pages
print(reader.pages[0].extract_text()) # the text on page 1
(Illustrative only, as noted above — not executed in this notebook.) Both libraries
mirror the same core idea as csv/json: parse the file's real structure (sheets and
cells; pages and their content) into Python objects, instead of handing back an
undifferentiated blob of bytes.