Reading & Writing Files
open(), file modes, the with statement, reading strategies for files of any size, encoding, and pathlib — the modern, portable way to work with paths.
open() and the Anatomy of a File Mode
Every interaction with a file on disk starts with the built-in open() function. It takes a path and, optionally, a mode that tells Python what you intend to do with the file — read it, overwrite it, add to it, or something else — and returns a file object that you then read from or write through.
f = open("notes.txt")
contents = f.read()
f.close()
print(contents)That example works, but it has a real problem this module will fix in the next Part. First, the modes. Every mode is a one- or two-character string, and getting the wrong one is one of the most common ways beginners accidentally destroy data they meant to keep.
"r" # read (default) — file must already exist, or FileNotFoundError is raised
"w" # write — creates the file if it doesn't exist,
# and COMPLETELY ERASES the existing contents if it does
"a" # append — creates the file if it doesn't exist,
# writes are added to the END of the existing contents
"x" # exclusive creation — creates the file, but FAILS with FileExistsError
# if the file already exists (useful for "never overwrite" safety)open("report.txt", "w") truncates report.txt to zero bytes immediately, even if an exception happens before you write a single character. This is the single most common way developers lose data during file work: opening an existing file in "w" mode "just to check something," and wiping it out in the process.The text/binary suffix — t and b
Each mode above can optionally take a second character: t for text (the default, if you omit it) or b for binary. "r" is shorthand for "rt"; "rb" reads raw bytes instead of decoded text. Part 06 below covers exactly when you need binary mode — for now, know that the suffix exists and that mixing it up (reading an image in text mode, for example) is a fast way to get a confusing error.
open("data.txt", "r") # read text (most common)
open("data.txt", "w") # write text, truncating
open("data.txt", "a") # append text
open("photo.jpg", "rb") # read binary
open("photo.jpg", "wb") # write binarywith open(...) — Why Manual close() Is a Real Liability
Every file you open holds a real operating-system resource — a file descriptor — that must be released with .close() when you are done. The manual version from Part 01 looks fine until something goes wrong between open() and close().
f = open("report.txt", "w")
f.write(generate_report()) # if this raises an exception...
f.close() # ...this line never runs. The file is left open.A single leaked file handle is harmless. Thousands of them, accumulating over the life of a long-running process — a web server, a data pipeline that runs for hours — will eventually exhaust the operating system's limit on open file descriptors and start raising OSError: Too many open files, usually far away from the code that actually caused it, which makes it a genuinely painful bug to trace.
Python's fix is the with statement, using files as a context manager. It guarantees the file is closed when the block ends — whether it ends normally or because an exception was raised partway through.
with open("report.txt", "w") as f:
f.write(generate_report())
# f.close() has already been called automatically here,
# even if generate_report() raised an exceptionopen()/close() pairs almost every time. The general mechanism behind with — a context manager, built on two special methods called __enter__ and __exit__ — is covered in full depth in the Context Managers module later in this track; files are simply the first, and most common, context manager you will use.Opening multiple files in one with statement
You can open more than one file in a single with statement, separated by commas — genuinely useful for a common pattern like reading from one file and writing a transformed version to another.
with open("input.txt") as src, open("output.txt", "w") as dst:
for line in src:
dst.write(line.upper())
# both files are guaranteed closed here, even if the loop raises partway throughread(), readline(), readlines() — and Why Iteration Beats All Three
A file object gives you several different ways to pull data out of it, and they are not interchangeable — each has a real trade-off, and picking the wrong one on a large file is a common cause of a script that "works fine locally" and then runs out of memory in production.
with open("log.txt") as f:
whole_thing = f.read() # one giant string — the ENTIRE file, all at once
with open("log.txt") as f:
one_line = f.readline() # a single line, including its trailing "\n"
with open("log.txt") as f:
all_lines = f.readlines() # a list of every line — the whole file, as a list
with open("log.txt") as f:
for line in f: # iterate the file object directly, one line at a time
process(line).read() and .readlines() both load the entire file into memory before you can do anything with it — .read() as one string, .readlines() as a list of strings. For a 4KB config file, that is completely fine. For a 40GB server log, it will exhaust available memory and crash the process before a single line has been processed.
Iterating the file object directly (for line in f:) reads one line at a time, on demand, and never holds more than the current line in memory regardless of how large the file is. This is the preferred pattern for anything that might be large, and it is genuinely no more verbose than the alternatives — there is rarely a good reason to reach for .readlines() over direct iteration.
for line in f: is memory-safe regardless of file size, reads naturally, and is what you will see in essentially all production Python code that processes files line by line. Reach for .read() only when you genuinely need the whole file as one string (e.g. passing it to json.loads(), covered in the next module) — and only when you are confident the file is small enough for that to be safe.Every line keeps its trailing newline
Whichever method you use, each line you get back includes its trailing "\n" character (except possibly the very last line, if the file doesn't end with one). This trips up almost everyone the first time — printing a line you read from a file produces an extra blank line, because print() already adds its own newline.
with open("names.txt") as f:
for line in f:
name = line.strip() # removes the trailing "\n" (and any surrounding whitespace)
print(f"Hello, {name}")write(), writelines(), and the Append Mode
Writing mirrors reading: .write() takes a single string, and .writelines() takes an iterable of strings, writing each one in sequence.
lines = ["First line\n", "Second line\n", "Third line\n"]
with open("output.txt", "w") as f:
f.writelines(lines)
# equivalent, written manually:
with open("output.txt", "w") as f:
for line in lines:
f.write(line).write() in a loop — if your strings don't already end in "\n", they will be written back-to-back with no separation at all. This is a genuinely common source of confusion, since the name suggests it works line by line the way print() does with a list."w" truncates, "a" appends — pick deliberately
The distinction from Part 01 matters most here. Opening a log file in "w" mode every time your program runs will silently discard every previous run's log the moment it starts — a genuinely common, genuinely damaging mistake for exactly the kind of file you least want to lose.
import datetime
def log_event(message):
with open("app.log", "a", encoding="utf-8") as f:
timestamp = datetime.datetime.now().isoformat()
f.write(f"{timestamp} — {message}\n")
log_event("Server started")
log_event("Received request from 10.0.0.4")
# Both lines accumulate in app.log across every call — nothing is overwritten.A useful mental rule: if the file is meant to represent a single, current snapshot (a generated report, a rewritten config), use "w". If the file is meant to accumulate a history over time (a log, an audit trail), use "a".
Always Pass encoding="utf-8" Explicitly
You met encoding in depth in the Strings module — the process of converting Python's internal Unicode text into raw bytes (and back). File I/O is exactly where that lesson stops being theoretical. Every text-mode open() call performs an encode or decode step behind the scenes, and if you don't say which encoding to use, Python falls back to whatever the operating system considers its default.
# No encoding specified — relies on the OS default
with open("customers.csv") as f:
data = f.read()
# On the developer's Mac, this default happens to be UTF-8. Fine, locally.
# On a Windows machine — or certain Linux server configurations — the
# default can be something else entirely, and a name like "José" or "Renée"
# in the file will raise a UnicodeDecodeError, or worse, decode SILENTLY WRONG.with open("customers.csv", encoding="utf-8") as f:
data = f.read()
with open("report.txt", "w", encoding="utf-8") as f:
f.write(report_text)encoding="utf-8" tends to pass every test on the author's own machine and then fail — or worse, silently corrupt data — the moment it runs somewhere with a different default. Treat a missing encoding argument on open() as a bug, not an oversight, every single time you see one in review.One exception: binary mode ("rb", "wb") never takes an encoding argument — there is no text decoding step involved at all, since you are reading and writing raw bytes directly. Passing encoding= alongside a binary mode raises a ValueError.
When You Actually Need "rb" and "wb"
Text mode is right for anything meant to be read as human-readable characters — .txt, .csv, .json, source code. Binary mode is for files whose content is not text at all: images, PDFs, compiled executables, audio, or any format with its own internal byte-level structure that Python should not try to interpret as characters.
with open("photo.jpg", "rb") as src:
image_bytes = src.read()
with open("photo_copy.jpg", "wb") as dst:
dst.write(image_bytes)
print(type(image_bytes)) # <class 'bytes'> — not strNotice the type: reading in binary mode returns a bytes object, not a str. This is the same bytes type from the Strings module — raw byte values with no assumption of what characters, if any, they represent. Trying to open a JPEG in text mode will either raise a UnicodeDecodeError almost immediately (since most of its bytes are not valid UTF-8) or, worse, silently corrupt the file if it happens not to error.
pathlib.Path — The Modern, Portable Way to Work With Paths
Every example so far has passed a plain string to open(), and that is completely valid Python. But real projects juggle a lot of path logic — joining directories, checking extensions, building paths that need to work identically on Windows, macOS, and Linux — and hand-building those with string concatenation is fragile.
folder = "data"
filename = "report.csv"
path = folder + "/" + filename # breaks on Windows, which uses backslashesThe standard-library pathlib module, and its central Path class, fixes this by representing a filesystem path as a proper object rather than a plain string — and it is genuinely the modern, idiomatic way to work with paths in Python, not just an alternative worth knowing about.
from pathlib import Path
folder = Path("data")
path = folder / "report.csv" # the / operator joins path segments — reads naturally,
# and produces the correct separator for the current OS
print(path) # data/report.csv (on macOS/Linux)
# data\report.csv (on Windows) — same code, correct on bothThe Path attributes and methods you'll use constantly
from pathlib import Path
p = Path("data/reports/q3_summary.csv")
print(p.name) # "q3_summary.csv" — the final component
print(p.stem) # "q3_summary" — filename without the extension
print(p.suffix) # ".csv" — just the extension
print(p.parent) # "data/reports" — the containing directory
print(p.parts) # ("data", "reports", "q3_summary.csv")Path objects work directly with open() — and have their own shortcuts
from pathlib import Path
p = Path("notes.txt")
# Path objects work directly wherever a path string would:
with open(p, encoding="utf-8") as f:
contents = f.read()
# Or skip open() entirely for simple cases:
contents = p.read_text(encoding="utf-8")
p.write_text("New contents\n", encoding="utf-8")os.path function calls. You will still see plain string paths in older code and in simple scripts — both are valid — but pathlib is what modern, professional Python code reaches for by default.Checking Whether a File Exists, and Creating Directories Safely
Before reading a file, it is often necessary to check whether it exists at all — attempting to open a nonexistent file in read mode raises FileNotFoundError, which you either need to guard against or handle explicitly.
from pathlib import Path
config = Path("config.json")
if config.exists():
settings = config.read_text(encoding="utf-8")
else:
settings = "{}" # fall back to an empty config
print(config.is_file()) # True if it exists AND is a regular file (not a directory)
print(config.is_dir()) # True if it exists AND is a directoryCreating directories — and doing it without race conditions
Writing to reports/2026/august/summary.csv fails with FileNotFoundError if the reports/2026/august/ directory chain doesn't already exist — open() never creates intermediate directories for you. Path.mkdir() handles this, with two keyword arguments worth knowing well.
from pathlib import Path
output_dir = Path("reports/2026/august")
output_dir.mkdir(parents=True, exist_ok=True)
# parents=True — create any missing intermediate directories (reports/, reports/2026/)
# instead of raising FileNotFoundError if they don't exist yet
# exist_ok=True — don't raise FileExistsError if the directory is already there;
# just treat it as success either way
(output_dir / "summary.csv").write_text("date,total\n", encoding="utf-8")if path.exists(): check and the code that acts on it, another process could theoretically create, delete, or modify that exact path — a narrow window, but a real one in concurrent or multi-process systems. For directory creation, exist_ok=True avoids the problem entirely by making "already exists" a non-error outcome rather than something you check for beforehand. For files you must not overwrite, the "x" mode from Part 01 is the safer tool — it fails atomically if the file already exists, rather than leaving a gap between checking and acting.The Nightly Export Job That Quietly Erased Itself — Denver, CO
A property management platform runs a nightly job that appends the day's completed maintenance tickets to a running export file, ticket_history.csv, which a downstream analytics team pulls into a dashboard every morning. One Tuesday, the analytics team reports the dashboard shows only a single day of data — six months of ticket history has vanished.
What the on-call engineer finds
A recent refactor had touched the export function. The original code opened the file in "a" mode, as it always had. During cleanup, someone had renamed a nearby variable and, in the process, accidentally changed the mode string too — a one- character edit, "a" to "w", that nobody caught in review because the surrounding logic looked identical.
def export_tickets(tickets):
with open("ticket_history.csv", "w", encoding="utf-8") as f: # was "a"
for ticket in tickets:
f.write(f"{ticket.id},{ticket.status},{ticket.closed_at}\n")
# Every night this ran, it truncated the file to zero bytes first (Part 01),
# then wrote only THAT NIGHT's tickets — silently discarding everything before it.Why it went unnoticed for so long
The job never raised an exception. "w" mode is completely legal, the write succeeded every single night, and the file existed with valid data in it — just one day's worth instead of the accumulating history everyone assumed was there. Nothing about the failure looked like a failure. This is exactly the danger flagged in Part 01 and Part 04: "w" versus "a" is a silent, successful-looking choice with two completely different outcomes.
The team's fix was two-fold: restore "a" mode, and add a regression test asserting the exported file's line count only ever grows between runs — turning a silent data-loss bug into a loud, immediate test failure the next time it happens.
Four Misconceptions About File I/O
5 Interview Questions — With Complete Answers
File I/O Mistakes Beginners Make Constantly
Errors You Will Hit With File I/O — And Exactly Why
🎯 Key Takeaways
- ✓open() takes a mode: "r" (read, default), "w" (write, truncates existing content), "a" (append), "x" (exclusive create). Add "b" for binary.
- ✓"w" mode erases the file the instant it is opened — not when you first write. Confusing "w" with "a" is one of the most damaging file bugs in real production code.
- ✓Always use with open(...) as f: — it guarantees the file is closed even if an exception is raised inside the block. Manual close() does not.
- ✓Iterate a file object directly (for line in f:) rather than using .readlines() — it processes one line at a time with constant memory use, regardless of file size.
- ✓Every line read from a file keeps its trailing "\n" — call .strip() if you don't want it.
- ✓Always pass encoding="utf-8" explicitly to open() for text files — the operating system default is not guaranteed to be UTF-8, and this is a common cause of code that works locally and fails in production.
- ✓Binary mode ("rb"/"wb") reads and writes raw bytes and never takes an encoding argument — use it for images, PDFs, and other non-text formats.
- ✓Prefer pathlib.Path over raw string paths — the / operator joins paths correctly across operating systems, and Path bundles both inspection (.name, .suffix, .parent) and filesystem operations (.exists(), .mkdir(), .read_text()) directly on the object.
- ✓Path("dir").mkdir(parents=True, exist_ok=True) safely creates a full directory chain, and is safe to call whether or not any part of it already exists.
What comes next
Module 16 builds directly on everything in this module — the csv and json modules for reading and writing the two formats every real Python script eventually touches, including the gotchas that break real data pipelines.
Module 16 → Working with CSV and JSONDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.