Files: Making Things Last 💾
Everything your programs have done so far vanished the moment they ended. Files are how a program remembers something tomorrow.
Writing a file
with open("crew.txt", "w") as f:
f.write("Guybrush\n")
f.write("Elaine\n")
f.write("Otis\n")
print("Written.")
Written.
Three things to notice:
"w"means write, and it destroys whatever was there without asking. Use"a"to append instead.\nis the newline.f.writedoes not add one for you, unlikeprint.withis doing something important, which is the next section.
| Mode | Means | If the file exists | If it does not |
|---|---|---|---|
"r" | read (the default) | reads it | FileNotFoundError |
"w" | write | empties it first | creates it |
"a" | append | adds at the end | creates it |
"x" | exclusive create | FileExistsError | creates it |
"rb" / "wb" | binary | for images, zips, anything not text |
Why with, and not just open
# The way you should never write it
f = open("notes.txt", "w")
f.write("something")
f.close() # if anything above raises, this never runs
# The way everyone writes it
with open("notes.txt", "w") as f:
f.write("something")
# closed automatically, even if an error was raised inside
print("done")
done
An open file holds an operating system resource, and on many systems the data you wrote
is not actually on disk until it is closed. with guarantees the close
happens no matter what, including when an exception fires halfway through. It is called
a context manager, you meet the machinery in Lesson 36, and until then
the rule is simply: always use with for files.
Reading it back, three ways
with open("crew.txt", "w") as f:
f.write("Guybrush\nElaine\nOtis\n")
# 1. the whole thing as one string
with open("crew.txt") as f:
print(repr(f.read()))
# 2. a list of lines
with open("crew.txt") as f:
print(f.readlines())
# 3. one line at a time, which is the one you want
with open("crew.txt") as f:
for line in f:
print(f" {line.strip()}")
'Guybrush\nElaine\nOtis\n'
['Guybrush\n', 'Elaine\n', 'Otis\n']
Guybrush
Elaine
Otis
Option three never holds more than one line in memory. Options one and two load the entire file. For a shopping list that is irrelevant. For an eight gigabyte log file it is the difference between a program that works and a machine that starts swapping and has to be rebooted.
Loop over the file object. It is shorter to type as well.
Note .strip() on every line: the newline character comes along with the
text, and forgetting it produces bugs where "Otis\n" != "Otis" and nobody
can see why.
Encodings: always say utf-8
with open("names.txt", "w", encoding="utf-8") as f:
f.write("Guybrush\nElaine\nMonkey 🐒\nZoë\n")
with open("names.txt", encoding="utf-8") as f:
for line in f:
print(line.strip())
Guybrush
Elaine
Monkey 🐒
Zoë
Without it, Python uses your operating system's default, which is UTF-8 on Mac and Linux and, historically, something else on Windows. That is why files written on one machine sometimes arrive as Zoë on another. Passing encoding="utf-8" every single time removes an entire genre of bug, and Python 3.15 is making it the default precisely because of how much pain it has caused.
pathlib: stop gluing strings together
from pathlib import Path
# the old way, which breaks on Windows
old = "data" + "/" + "crew.txt"
# the way that works everywhere
p = Path("data") / "crew.txt"
print(old)
print(p)
print(p.name, p.stem, p.suffix)
print(p.parent)
data/crew.txt
data/crew.txt
crew.txt crew .txt
data
The / operator on a Path joins path parts with the right
separator for the machine it runs on. pathlib also folds most of the file
operations you need into one object:
from pathlib import Path
notes = Path("notes.txt")
notes.write_text("Remember the rubber chicken.\n", encoding="utf-8")
print(notes.read_text(encoding="utf-8").strip())
print(notes.exists())
print(notes.stat().st_size, "bytes")
notes.unlink() # delete
print(notes.exists())
Remember the rubber chicken.
True
29 bytes
False
Checking before you leap
from pathlib import Path
wanted = Path("does-not-exist.txt")
if wanted.exists():
print(wanted.read_text())
else:
print("No such file, using defaults instead.")
# The more Pythonic version: try it and handle failure
try:
print(wanted.read_text())
except FileNotFoundError:
print("Still not there.")
No such file, using defaults instead.
Still not there.
Both are fine. The second is generally preferred in Python, and there is even a slogan
for it: "easier to ask forgiveness than permission". The reason is not style, it is that
between your exists() check and your read, another program
could delete the file. Handling the error covers both cases. Lesson 22 is entirely about
this.
Walking a folder
from pathlib import Path
# make a small tree to explore
Path("ship/cargo").mkdir(parents=True, exist_ok=True)
Path("ship/log.txt").write_text("day one\n", encoding="utf-8")
Path("ship/cargo/grog.txt").write_text("47 barrels\n", encoding="utf-8")
Path("ship/cargo/map.txt").write_text("x marks it\n", encoding="utf-8")
print("Top level only:")
for item in sorted(Path("ship").iterdir()):
kind = "dir " if item.is_dir() else "file"
print(f" {kind} {item}")
print("Every .txt, all the way down:")
for item in sorted(Path("ship").rglob("*.txt")):
print(f" {item} ({item.stat().st_size} bytes)")
Top level only:
dir ship/cargo
file ship/log.txt
Every .txt, all the way down:
ship/cargo/grog.txt (11 bytes)
ship/cargo/map.txt (11 bytes)
ship/log.txt (8 bytes)
glob("*.txt") looks in one folder, rglob recurses into every
subfolder. Those two lines replace a startling amount of the shell scripting people
write, and this is the foundation of the file-organiser project in the workshop.
These blocks create real files when you run them. In the browser they land in a sandbox that vanishes when you reload, so nothing on your computer is touched. On your own machine they appear in whichever folder you ran Python from, which is a good reason to keep a scratch folder for experiments.
A real one: a note-taking script
from pathlib import Path
from datetime import datetime
NOTES = Path("captains_log.txt")
def add_note(text):
"""Append a timestamped line to the log."""
stamp = datetime(1990, 10, 15, 9, 30).strftime("%Y-%m-%d %H:%M")
with open(NOTES, "a", encoding="utf-8") as f:
f.write(f"[{stamp}] {text}\n")
def show_notes():
"""Print the log, or say so if it is empty."""
if not NOTES.exists():
print("No log yet.")
return
for i, line in enumerate(NOTES.read_text(encoding="utf-8").splitlines(), start=1):
print(f"{i}. {line}")
add_note("Became a mighty pirate.")
add_note("Lost the ship. Again.")
show_notes()
1. [1990-10-15 09:30] Became a mighty pirate.
2. [1990-10-15 09:30] Lost the ship. Again.
Word count on a file
Write a file with several lines, then report the number of lines, words and characters, in the style of the Unix wc command.
Reveal solution
from pathlib import Path
text = """It is a rubber chicken.
It has a pulley in the middle.
Do not ask why.
"""
Path("chicken.txt").write_text(text, encoding="utf-8")
content = Path("chicken.txt").read_text(encoding="utf-8")
lines = content.splitlines()
words = content.split()
print(f"{len(lines):4} lines")
print(f"{len(words):4} words")
print(f"{len(content):4} characters")
3 lines
16 words
71 charactersFilter a file into another file
Read a log file and write a second file containing only the ERROR lines, then report how many were found.
Reveal solution
from pathlib import Path
Path("server.log").write_text("""INFO started
ERROR disk full
INFO retrying
ERROR still full
INFO gave up
""", encoding="utf-8")
errors = []
with open("server.log", encoding="utf-8") as source:
for line in source:
if line.startswith("ERROR"):
errors.append(line)
with open("errors.log", "w", encoding="utf-8") as target:
target.writelines(errors)
print(f"{len(errors)} errors extracted")
print(Path("errors.log").read_text(encoding="utf-8").strip())
2 errors extracted
ERROR disk full
ERROR still fullSafe overwrite
Write a function that saves text to a file but refuses to destroy an existing one unless told it may. Prove both branches.
Reveal solution
from pathlib import Path
def save(path, text, overwrite=False):
"""Write text to path. Refuses to clobber unless overwrite is True."""
target = Path(path)
if target.exists() and not overwrite:
return f"refused: {target} already exists"
target.write_text(text, encoding="utf-8")
return f"wrote {len(text)} characters to {target}"
print(save("treasure.txt", "x marks the spot"))
print(save("treasure.txt", "no it does not"))
print(save("treasure.txt", "fine, it does not", overwrite=True))
print(Path("treasure.txt").read_text(encoding="utf-8"))
wrote 16 characters to treasure.txt
refused: treasure.txt already exists
wrote 17 characters to treasure.txt
fine, it does notDefaulting to refuse is the right instinct for anything destructive. Python's "x" mode does the same job at the operating system level, which is even safer because there is no gap between the check and the write.