Reading and Writing Files
Opening files so they always close, text versus bytes, encodings and the errors they cause, and building paths that work on any operating system.
Opening files so they always close, text versus bytes, encodings and the errors they cause, and building paths that work on any operating system.
Everything your program has produced so far vanished when it finished. Print something to the screen and it exists until you close the terminal; store something in a variable and it exists until the program ends. Nothing survives.
Files are how a program remembers. By the end of this lesson you will read a file, write one, build paths that work on anyone's computer, and understand the one setting that decides whether text with an accent in it arrives intact or as a stack of question marks.
The way to open a file is with:
with open("captions.txt") as file:
contents = file.read()
print(contents)open gives you a file object. with guarantees that when
the indented block ends the file is closed — whether the block
finished normally, hit a return, or raised an exception.
That guarantee is the whole point. An open file holds an operating-system resource, and a program that opens thousands without closing them eventually fails with "too many open files". Worse, on writing, data often sits in a buffer until the file closes — so an unclosed file can be a file with nothing in it.
You can open a file without with:
file = open("captions.txt")
contents = file.read()
file.close() # and if read() raised, this never runsThis is the pattern the previous lesson's finally existed for,
and with is that pattern with the mistake designed out. Use
with every time.
with open("captions.txt") as file:
everything = file.read() # one big stringwith open("captions.txt") as file:
lines = file.readlines() # a list of lineswith open("captions.txt") as file:
for line in file: # one line at a time
print(line.strip())The third is usually the right one. The first two load the whole file into memory, which is fine for a shopping list and fatal for a four-gigabyte log. Looping over the file object reads a line, hands it to you, and forgets it — so memory use stays flat no matter how large the file is.
Note the .strip(). Each line arrives with its newline
character still attached, so "dawn.jpg\n" is what you get, and
comparing it to "dawn.jpg" fails in a way that is invisible on
screen.
open takes a second argument saying what you intend:
with open("output.txt", "w") as file: # write - TRUNCATES first
file.write("Ana Duarte\n")
file.write("Bruno Silva\n")
with open("output.txt", "a") as file: # append - adds to the end
file.write("Carla Reis\n")There are four, and one of them is destructive.
The default. Changes nothing.
Raises FileNotFoundError if there is nothing there.
Empties the file immediately.
Before you write a single byte. No confirmation, no undo — open the wrong path and its contents are gone even if your program then crashes.
Adds at the end, keeps what is there.
What you want for logs and for anything you are building up across several runs.
Refuses if the file exists.
The safe way to say "this should be a new file". Raises
FileExistsError instead of overwriting something you did
not know was there.
write does not add newlines. You supply them, or the whole
file ends up on one line. For a list of lines, writelines is
available but has the same rule — it is really "write these
strings one after another", so you still add the \n yourself:
with open("output.txt", "w") as file:
file.writelines(f"{name}\n" for name in names)A file on disk holds bytes. A string in Python holds characters. The rule for converting between them is an encoding, and if the rule used to read differs from the rule used to write, you get either mangled text or an exception.
The one to use is UTF-8, which can represent every character there is. Say so explicitly:
with open("captions.txt", encoding="utf-8") as file:
...Bad — relying on whatever the machine defaults to.
with open("captions.txt") as file:
for line in file:
process(line)Good — naming the encoding.
with open("captions.txt", encoding="utf-8") as file:
for line in file:
process(line)Without encoding=, Python picks a default that has
historically depended on the operating system and its locale
settings — which means the same file read on two machines can
produce two different strings.
Your machine writes "Praça" as bytes
Under UTF-8, the ç becomes two bytes. The file on disk is correct.
A colleague's machine reads it with another rule
Their default is not yours. Those two bytes now mean something else.
Either it fails loudly, or it does not
UnicodeDecodeError is the good outcome. The bad one is
Praça — accepted without complaint and written into your
database.
Building file paths by joining strings breaks across operating
systems, because Windows separates with \ and everything else
with /. pathlib handles it:
from pathlib import Path
photos_dir = Path("photos")
one_photo = photos_dir / "dawn.jpg" # the / operator joins paths
print(one_photo) # photos/dawn.jpg (or photos\dawn.jpg)That / is not division. Path gives it a second meaning:
join these path pieces correctly for this machine.
Path objects answer the questions you actually have:
p = Path("photos/sunrise_over_lisbon.jpg")
print(p.name) # sunrise_over_lisbon.jpg
print(p.stem) # sunrise_over_lisbon - no extension
print(p.suffix) # .jpg
print(p.parent) # photos
print(p.exists()) # True or False
print(p.is_file()) # is it a file
print(p.stat().st_size) # size in bytesAnd they find things:
for photo in Path("photos").glob("*.jpg"): # this folder
print(photo.name)
for photo in Path("photos").rglob("*.jpg"): # and every subfolder
print(photo)Path also reads and writes short files in one line, which is
often all you need:
text = Path("captions.txt").read_text(encoding="utf-8")
Path("output.txt").write_text(content, encoding="utf-8")Making directories:
Path("output/2026").mkdir(parents=True, exist_ok=True)parents=True creates intermediate folders; exist_ok=True
means "fine if it is already there" rather than raising.
Two formats cover most of what you will exchange with other programs, and both have a standard-library module. Do not parse either by hand.
CSV is tabular data, and split(",") is not good enough for
it — a field containing a comma is quoted, and splitting cuts it
in half:
import csv
with open("photos.csv", encoding="utf-8", newline="") as file:
for row in csv.DictReader(file):
print(row["name"], row["size"])DictReader uses the header row to give you a dictionary per
row, so you refer to row["size"] rather than row[2]. The
newline="" argument is required by the csv module and prevents
line-ending trouble; pass it and move on.
JSON is nested data, and the module turns it into dictionaries and lists directly:
import json
with open("config.json", encoding="utf-8") as file:
config = json.load(file)
print(config["output_folder"])
with open("results.json", "w", encoding="utf-8") as file:
json.dump(results, file, indent=2)indent=2 makes the output readable by humans and diffable in
version control, which is worth the extra bytes for anything a
person might open.
OPENING - always with, always encoding
with open(path, encoding="utf-8") as file:
...
MODES
"r" read (default)
"w" write - EMPTIES the file immediately
"a" append
"x" create, fail if it exists <- the safe "new file"
READING
file.read() the whole thing as one string
file.readlines() a list of lines
for line in file: one at a time <- prefer this
line.strip() lines keep their \n
WRITING
file.write(text) no newline added - you supply \n
PATHS - from pathlib import Path
Path("a") / "b.jpg" joins correctly on every OS
p.name p.stem p.suffix p.parent
p.exists() p.is_file() p.stat().st_size
p.glob("*.jpg") this folder
p.rglob("*.jpg") and all subfolders
p.read_text(encoding="utf-8")
p.write_text(s, encoding="utf-8")
p.mkdir(parents=True, exist_ok=True)
STRUCTURED
csv.DictReader(file) row["name"], not row[2]
open with newline=""
json.load(file) file -> dicts and lists
json.dump(obj, file, indent=2)
RULES
always use `with` closing is not optional
always pass encoding="utf-8" the default is not portable
never errors="ignore" that is silent data loss
"w" destroys before it writes
never parse CSV with split(",")Your programs can now remember things between runs, read data
other programs produced, and write data others can use. The two
habits to keep are small and non-negotiable: with every time,
and encoding="utf-8" every time. Both prevent bugs that appear
on someone else's machine rather than yours, which are the
expensive kind.
Next is Organising Code into Modules, which addresses the
other thing that has been growing quietly — the single file your
whole program lives in. That lesson splits it up, explains what
import actually does, and shows the layout that will not fight
you when the program gets real.
Before you move on, write a program that reads a folder of files
with rglob, collects something about each one, and writes the
result as JSON. Then open the JSON and read it back. That
round trip — disk to program, program to disk — is the shape of
an enormous amount of real software, and doing it once end to
end makes the rest recognisable.