Level 3 · Real Programs

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:

ModeMeansIf the file existsIf it does not
"r"read (the default)reads itFileNotFoundError
"w"writeempties it firstcreates it
"a"appendadds at the endcreates it
"x"exclusive createFileExistsErrorcreates it
"rb" / "wb"binaryfor 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
INTERFACING[Medium: Success]

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ë
🌍 Always pass encoding='utf-8'

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.

🧹 A note about these examples

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.
Exercise 1

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 characters
Exercise 2

Filter 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 full
Exercise 3

Safe 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 not

Defaulting 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.

+100 XP